资讯动态

SpringBoot+Vue3+MyBatis实战:从零构建专辑鉴赏网站全流程

发布时间:2026/10/8 14:49:57 来源:尧图企业网站定制
说实话做完这个专辑鉴赏网站项目我最大的感受是它业务看着不大但前后端该碰的东西一样没少。SpringBoot 做接口、Vue3 写页面、MyBatis 管 SQL、MySQL 存数据整套走下来基本把一个后台管理系统加 C 端展示站的完整链路都打通了。一开始接这个需求的时候朋友形容得特别轻巧“就是个能看专辑、能评论打分的小网站”结果从数据库设计到部署上线零零碎碎踩了不少坑。这篇文章就把整个项目的设计思路、表结构、关键实现、联调部署以及我实际踩过的坑都摊开来说给正在做类似网站或者想用 SpringBoot Vue3 MyBatis 这套组合练手的朋友一个完整参考。1. 项目需求与技术选型先想清楚要做什么再定技术栈1.1 从用户视角拆一遍业务比先写代码更重要拿到“专辑鉴赏网站”这个需求第一个动作不是建 SpringBoot 工程而是把所有用户可能做的操作在纸上过一遍。所谓“鉴赏”本质上就是两种角色访客看内容、管理员管内容。访客可以浏览专辑列表、按分类筛选、点进详情页看介绍、翻其他人的鉴赏评价然后登录之后自己也能打分、写评论、收藏专辑。管理员则要维护专辑信息、上传封面图、管理用户评论必要的时候还能看看收藏数据和评分情况。这个流程拆清楚了后面写代码自然就有方向。而且“专辑”这个东西并不局限于音乐专辑你把它换成摄影专辑、画作专辑、甚至游戏原声带功能模型都是通用的区别只在字段设计上。所以我在整理需求文档的时候特意把“专辑详情 评价 收藏 后台管理”这四个模块标为核心功能凡是跟核心功能无关的额外想法一律先放一放。1.2 为什么偏偏是 SpringBoot Vue3 MyBatis MySQL这一套组合在前端分离的 Java 项目里几乎成了标准答案。我简单对比一下几种常见方案方案优点缺点JSP / Thymeleaf 前后端一体上手快、没有跨域问题前后端耦合严重页面迭代和接口调整互相拖累SpringBoot 后端 Vue3 前端分离接口和页面各自独立团队可并行开发后期加移动端可直接复用接口需要处理跨域、鉴权、打包部署这些额外工作后端用 Python Flask / Node.js开发效率高代码量少如果团队本身是 Java 技术栈后续维护和部署都不占优势我最终选 SpringBoot Vue3 组合主要是看中两点一是 SpringBoot 的自动配置极大减少了传统 SSM 项目里的 XML 配置开发效率高二是 Vue3 的组合式 API 写业务逻辑比 Vue2 的 Options API 更直接配合 Pinia 做状态管理非常顺手。MyBatis 则是为了保证复杂 SQL 的可控性——鉴赏网站里“专辑列表 评分排序 关键字搜索 分类过滤”这种组合条件特别多用 MyBatis 的 XML 写动态 SQL比在代码里拼字符串舒服太多。MySQL 就不多说了这套项目的数据量没到需要上分布式数据库的程度单机 MySQL 最稳也最省钱。1.3 功能模块清单照着这个开发就不会漏我把整个系统拆成用户端和管理端列表如下模块功能描述说明用户注册登录用户名密码注册、登录、退出密码用 BCrypt 加密存储登录后返回 token专辑展示专辑列表、分类筛选、关键字搜索、分页列表页按评分和浏览量排序支持上下架状态专辑详情封面、艺人、发行日期、简介、评分汇总详情页渲染后端返回的完整专辑对象鉴赏评价查看评论列表、发表评论、打 1-5 星评分评论默认展示后台可删除收藏功能收藏 / 取消收藏专辑、我的收藏列表每个用户对同一张专辑只能收藏一次后台管理专辑增删改查、封面上传、评论管理、用户管理管理员角色专属普通用户不能访问每个模块拆出来工作量都不大但组合在一起就是一个完整的业务闭环。我建议第一次做这类项目的朋友也按这个思路来先列功能清单再设计表结构最后才是动手写接口和页面顺序反了后面大概率要返工。2. 数据库设计地基没打好后面全是坑2.1 六张核心表以及字段设计思路数据库设计的核心是回答三个问题系统里有哪些实体、实体之间什么关系、每个实体需要哪些信息。我的专辑鉴赏网站最终落定了六张表用户表、专辑表、分类表、评论表、收藏表再加上管理员用的操作日志表可选但我建议加后面答辩或复盘都有用。专辑表是最核心的一张实际字段如下CREATE TABLE album ( id int NOT NULL AUTO_INCREMENT, title varchar(200) NOT NULL COMMENT 专辑名称, artist varchar(100) DEFAULT NULL COMMENT 艺人 / 作者, category_id int DEFAULT NULL COMMENT 分类ID, cover_url varchar(500) DEFAULT NULL COMMENT 封面图URL, description text COMMENT 专辑简介, release_date date DEFAULT NULL COMMENT 发行日期, rating_avg decimal(3,2) DEFAULT 0.00 COMMENT 平均评分, rating_count int DEFAULT 0 COMMENT 评分人数, view_count int DEFAULT 0 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), KEY idx_category (category_id), KEY idx_status_view (status, view_count) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT专辑表;字段里有几个点值得细说。首先是rating_avg和rating_count这两个字段是冗余存储的。最笨的办法是每次展示列表的时候实时对评论表做AVG(rating)和COUNT(*)但一次列表加载几十张专辑每张都要聚合一次评论表查询量直接翻倍卡顿是必然的。把聚合结果更新到专辑表里虽然多了一个“评价后更新专辑”的逻辑但查询压力小很多。第二个是view_count浏览量不要求特别精确用字段累加就可以没必要上独立的日志表那是数据量到百万级以后才考虑的优化。用户表比较简单id、username、password、nickname、avatar、role、create_time。角色字段用role区分 admin 和 user不要单独建权限表这种体量的系统没必要把权限设计复杂化。评论表要记录user_id、album_id、content、rating、status、create_time其中status默认 1 表示展示管理员可以把违规评论改成 0而不需要物理删除这样对用户来说“这条评论被屏蔽了”对后台来说数据还能留底。收藏表就是user_id album_id联合唯一索引保证同一个用户不能重复收藏同一张专辑。2.2 外键到底建不建我的答案是逻辑关联很多初学 MySQL 的朋友特别喜欢给每张表都加上物理外键约束觉得这样才能保证数据一致性。我的经验是这种小项目不建议加物理外键。原因是外键约束会让每次插入和删除都去检查关联表增加不必要的锁开销更关键的是后期如果要分库分表或者做数据迁移物理外键会变成巨大的障碍。数据一致性完全可以在 Service 层用代码保证比如用户评论的时候先检查 album_id 是否存在收藏之前先检查用户是否登录。逻辑关联配合代码校验是绝大多数互联网项目的实际做法。索引方面我建议除了主键之外重点建这三类专辑表的category_id索引因为列表页经常按分类筛选专辑表的status view_count联合索引因为默认查询条件是 status1 并按浏览量排序评论表的album_id create_time联合索引详情页加载评论列表时最常用这个条件。2.3 建表最容易踩的三个细节第一编码必须用utf8mb4别用utf8mb3或老的latin1否则用户评论里带个 emoji 表情直接插入失败。第二时间字段我统一用datetime不搞timestamp因为 timestamp 有 2038 年的上限问题而且受时区影响格式化处理起来容易出幺蛾子。第三所有表都要有create_time和update_time这个习惯能救命——排查数据问题的时候没有时间字段真的无从下手。3. 后端 SpringBoot 实战分层结构、动态 SQL 与登录鉴权3.1 Controller-Service-Mapper 分层每层只干一件事后端我严格按照controller - service - mapper三层去写中间穿插entity、dto、vo。很多初学者喜欢把业务逻辑一股脑写在 Controller 里图省事但一旦接口变复杂Controller 膨胀到几百行的时候你就知道后悔了。分层的意义很简单Controller 只负责接收参数和返回结果Service 只负责业务逻辑Mapper 只负责数据库操作。每一层的职责单一出问题定位就快。有一点强烈建议接收前端参数的时候用 DTO不要直接拿实体类接收。比如用户注册的接口实体类 User 里有一个role字段如果你直接用 User 接收前端传参攻击者往请求体里塞一个role:admin直接就提权了。而用 DTO比如RegisterDTO只定义 username、password、nickname 这几个字段多余字段直接被忽略安全。这个坑我在早期项目里踩过后来养成了所有写操作都用 DTO 接收的习惯。3.2 MyBatis 动态 SQL组合条件查询的关键列表页的搜索功能看起来简单真正写 SQL 的时候才发现条件组合太多了选了分类、输入了关键字、还要按评分排序每种组合都要拼一个不同的 SQL。用注解Select写这种场景会非常痛苦所以我选择了 XML 文件方式。举一个实际用到的查询例子select idselectAlbumPage resultTypecom.example.entity.Album SELECT * FROM album where if testcategoryId ! null AND category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR artist LIKE CONCAT(%, #{keyword}, %)) /if AND status 1 /where ORDER BY choose when testsortType ratingrating_avg DESC/when when testsortType latestcreate_time DESC/when otherwiseview_count DESC/otherwise /choose LIMIT #{offset}, #{limit} /selectwhere标签会自动去掉多余的 ANDchoose标签处理排序字段这就是 MyBatis 动态 SQL 的价值。分页我这里直接用了LIMIT手写因为项目数据量不大不需要引入 PageHelper 这种分页插件。数据量到大几万条再考虑分页插件现阶段越简单越不容易出错。3.3 登录鉴权JWT 拦截器不用 Spring Security登录鉴权这块我选择了 JWTJSON Web Token加上自己写的拦截器没有引 Spring Security。原因很现实这个项目的权限模型只有“登录用户”和“管理员”两种角色Spring Security 的过滤器链、UserDetailsService、权限表达式配置前期学习成本太高对一个以业务展示为主的网站来说有点杀鸡用牛刀。JWT 的方案更直观用户登录成功后后端生成一个包含 userId、role、过期时间的 token 返回给前端前端每次请求在请求头里带上 token后端拦截器校验 token通过就放行并把用户信息放入请求上下文。密码存储必须用 BCrypt绝不能明文存或 MD5。MD5 是摘要算法撞库的时候几乎秒破。Spring Security 里的BCryptPasswordEncoder可以单独拿出来用或者引入spring-security-crypto依赖只需一个工具方法就能完成加密和校验。登录接口的密码校验逻辑就两行// 加密新用户注册时 String encodedPwd passwordEncoder.encode(rawPassword); // 校验登录时 if (!passwordEncoder.matches(rawPassword, user.getPassword())) { throw new BusinessException(用户名或密码错误); }拦截器放行路径要特别注意登录接口、注册接口、专辑列表和详情这种公开页面必须放行否则游客根本进不了网站这是写拦截器最容易翻车的地方。3.4 封面上传本地存储 虚拟路径映射专辑封面本质上是一张图片文件我采用了最简单的本地存储方案上传接口接受MultipartFile把文件保存到服务器指定目录数据库里只存访问 URL。application.yml里配一个上传路径和静态资源映射file: upload-dir: /data/album-covers/ spring: mvc: static-path-pattern: /uploads/** resources: static-locations: file:${file.upload-dir}这样配置之后保存为/uploads/album-123.jpg的字符串前端直接就能通过该路径访问图片。这里有两个细节提醒一下文件命名必须用 UUID 重新生成不要用用户上传的原始文件名否则两个用户传了个同名文件就互相覆盖了再一个就是上传大小要限制SpringBoot 默认单文件最大 1MB我在配置里调到了 5MB超过直接报错。4. 前端 Vue3 项目组合式 API Element Plus 快速建站4.1 用 Vite 初始化项目目录结构一次摆好前端我用 Vite 创建了 Vue3 项目命令就是熟悉的npm create vitelatest album-front -- --template vue。创建完先不急着写页面把目录结构规划好src/ api/ # 接口请求定义 assets/ # 静态资源 components/ # 通用组件 router/ # 路由配置 stores/ # Pinia 状态 views/ # 页面组件 utils/ # 工具函数依赖方面装了 vue-router、pinia、axios、element-plus、dayjs 这几个。Element Plus 对 Vue3 的支持已经很成熟后台管理的表格和表单直接用它能省掉大量手写样式的时间。4.2 路由守卫 Pinia把登录状态管起来登录状态管理用 Pinia 非常直接。我建了一个stores/user.js里面定义token和userInfo两个 state登录成功之后调用接口把 token 存起来同时写入 localStorage这样刷新页面状态不丢。Pinia 相比 Vuex 最大的优势就是没有那么多概念不需要 mutation直接改 state 就行代码量减少了一大截。路由守卫的作用是拦截未登录用户访问需要鉴权的页面。在router.beforeEach里判断如果目标路由的meta.requiresAuth为 true同时 store 里没有 token就让用户跳转到登录页。后台管理页面更是要双重校验不仅要求登录还要求角色是 admin否则直接跳到首页。4.3 Axios 封装统一处理 token 和错误提示Axios 必须封装别在每个页面里直接写axios.get。我在utils/request.js里做了统一处理请求拦截器从 Pinia 或 localStorage 里取 token放到请求头Authorization里响应拦截器统一处理后端返回结构比如code 0才返回业务数据code 401就清除本地登录信息并跳登录页。这样每个页面调用接口的时候只需要关心业务数据不需要每次都写错误处理逻辑。import axios from axios import { useUserStore } from /stores/user import router from /router const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 10000 }) request.interceptors.request.use(config { const store useUserStore() if (store.token) { config.headers.Authorization Bearer ${store.token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 0) return res.data if (res.code 401) { const store useUserStore() store.logout() router.push(/login) } return Promise.reject(new Error(res.message || 请求失败)) }, error { return Promise.reject(error) } ) export default request4.4 三个核心页面的实现要点专辑列表页是 C 端最重要的页面我用了响应式卡片布局每张卡片显示封面、专辑名、艺人、评分点击跳转详情。搜索栏和分类筛选放在顶部筛选条件变化时重新请求接口。这里注意一点搜索和筛选的状态必须放在 URL 的 query 参数里不能只放在组件内部否则用户刷新页面后筛选条件就丢了。详情页比较琐碎左边封面图右边专辑信息下面评论列表最底下是评论表单。评分组件直接用 Element Plus 的el-rate用户选择星星后提交提交成功后列表重新加载同时顶部的平均分和评分人数也要跟着刷新。这里我额外做了个处理同一个用户对同一张专辑重复评论时前端直接提示“你已评价过这张专辑”后端接口也会做同样的校验双重保险防止刷分。后台管理页面结构更简单左侧菜单、右侧内容区。专辑管理是表格加弹窗表单行内直接放编辑和删除按钮封面用el-upload上传。用户管理就是简单的表格展示支持搜索和冻结账号。评论管理是重点因为后台要能屏蔽违规评论所以表格里要有一列状态展示操作列放“屏蔽 / 解除屏蔽”按钮。5. 联调、打包与部署从本地跑通到服务器上线5.1 开发环境的跨域用 Vite 代理别手写 CORS 乱放行前后端分离项目开发阶段必然遇到跨域问题。最优雅的方案是配置 Vite 的 proxy让前端开发服务器把/api开头的请求转发到后端浏览器看到的请求是同源的跨域问题根本不存在。// vite.config.js export default defineConfig({ server: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })注意如果你用了 Vite 代理后端就不要再配置CrossOrigin或全局 CORS 过滤器了。两边都配虽然能工作但等于留了一个安全隐患——生产环境如果忘了关 CORS任何网站都能通过浏览器直接请求你的后端接口。我的习惯是开发环境走代理生产环境走 Nginx 代理后端全程不放行跨域这是最干净的做法。5.2 前端构建 后端打包然后交给 Nginx上线部署的流程分三步。第一步在前端项目根目录执行npm run build生成dist目录。第二步在后端目录执行mvn clean package -DskipTests生成可执行的 jar 包。第三步把dist目录里的文件复制到服务器用 Nginx 托管同时配置反向代理把/api路径的请求转发到后端进程。Nginx 配置的核心就这一段server { listen 80; server_name your-domain.com; location / { root /var/www/album-front; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }前端的try_files是 SPA 的关键否则一个路由刷新就 404。后端 jar 包通过java -jar album-backend.jar启动用nohup放在后台运行。如果你不想单独部署 Nginx也可以把构建好的dist目录拷进 SpringBoot 项目的src/main/resources/static下重新打包这样前端页面和后端接口由同一个端口提供服务部署更简单。但这种方式不适合前后端并行迭代我只在小项目里图省事的时候才这么干。5.3 环境配置分离dev 和 prod 别用同一套数据库开发和生产环境的后端配置必须分开。我在src/main/resources下建了application-dev.yml和application-prod.yml公共配置放在application.yml里然后通过spring.profiles.activedev或prod切换。dev 环境连本地 MySQL日志级别开 DEBUG 方便排查prod 环境连服务器数据库日志级别调成 INFO把日志文件写入指定目录方便回溯。数据库连接信息这种敏感配置生产环境建议用环境变量注入而不是写死在 yml 里提交到代码仓库。比如spring.datasource.password${DB_PASSWORD}启动的时候export DB_PASSWORDxxx或者写进启动脚本至少不能明文躺在项目代码里。6. 实际开发中踩过的坑常见问题与排查速查6.1 列表接口返回的 JSON 字段全是 null原因只有一个第一次写完专辑列表接口前端一测发现title有值但create_time、release_date全是 null折腾了好久才发现问题在实体类字段映射。MySQL 里字段是下划线风格create_timeJava 实体类里是驼峰风格createTimeMyBatis 默认不自动转换于是查出来就是 null。解决办法是在application.yml加一行配置mybatis: configuration: map-underscore-to-camel-case: true加完重启就正常了。如果没用全局配置就必须手工写resultMap把每个字段的映射关系列一遍那代码量就上来了。6.2 发表评论后专辑评分没更新事务没生效项目里有个逻辑用户给专辑打 1-5 星并提交评论后后端要同时做两件事——插入评论记录、更新专辑表的rating_avg和rating_count。我一开始把这两步写在同一个 Service 方法里结果评论明明插入成功了专辑评分却纹丝不动。排查了半天发现两个原因一是这个方法没加Transactional写库第一步成功、第二步抛异常时不会回滚二是Transactional加在了同类内部调用的方法上Spring 的 AOP 代理只拦截外部调用的方法自调用会绕开代理事务也就没生效。正确的姿势是把“插入评论 更新专辑评分”放到同一个类的独立方法里这个方法加Transactional由 Controller 层调用。实际后面我再写类似逻辑都会刻意检查“这个写入操作是不是涉及多张表”是的话就在方法上标事务保证原子性。6.3 Vue3 里明明改了数据页面却不刷新Vue3 的响应式系统有两个 APIref和reactive。刚开始我把列表数据用reactive定义接口返回之后直接list res.data结果页面毫无反应。原因是我用reactive定义的是一个引用重新赋值等于让这个引用指向了新的对象原来的响应式连接就断了。正确的做法是用ref定义列表数据赋值的时候操作.value属性或者用reactive定义对象后修改其内部属性。这个坑在 Vue3 初学者里出现频率特别高。另外一个细节从接口拿到的数组如果你直接修改数组某个索引位置的对象属性Vue3 的ref是可以触发更新的但如果你是“整体替换数组”务必用.value newArray的方式不要做splice(0, list.length, ...newArray)这种绕来绕去的操作。6.4 时间显示成了 NaNJava 日期被转成了什么鬼前端用new Date(2024-05-01T10:00:00)渲染时间老是显示 NaN后来发现是后端把LocalDateTime序列化成了2024-05-01T10:00:00这种 ISO 格式年显示没问题但有些前端机型对 ISO 的解析兼容性不好。最后统一在后端配置了 Jackson 的日期格式spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8前端拿到的时间字符串直接用 dayjs 格式化显示再也没出现过 NaN。时区也要注意后端时区必须设成 GMT8否则服务器在 UTC 时区的时候时间会差 8 个小时。6.5 问题排查速查表遇到直接对照现象可能原因解决办法接口返回字段 null数据库下划线字段和 Java 驼峰字段不匹配开启map-underscore-to-camel-case或手写 resultMap写入操作只成功了一半Service 方法没加事务或事务自调用加Transactional避免同类内部调用Vue3 赋值不更新页面reactive整体替换改用ref并赋值.value时间显示 NaN 或少 8 小时Jackson 格式和时区没配配置date-format和time-zoneGMT8前端请求 404接口路径和代理路径不一致确认baseURL是/api后端 Controller 映射也是/api开头后端中文乱码数据库编码不是 utf8mb4建表语句统一用DEFAULT CHARSETutf8mb4项目启动后端口冲突8080 被占用改用server.port配置换端口或lsof查占用进程6.6 给初学者的几个建议如果这个项目你是第一次完整做前后端分离我的建议只有一条先把数据库表和核心接口调通再开始写前端页面。很多人喜欢从页面做起结果页面写完了发现接口返回的数据结构不对又要回头改接口两边来回折腾。其次接口的返回结构一定统一。我在项目里定义了全局返回体ResultT包含code、message、data三个字段所有接口都返回这个结构前端 Axios 拦截器只需要处理一次。这个习惯越早养成越好因为接口一多每个人返回结构各写各的联调阶段纯属浪费生命。最后说一句关于代码量的实话这整套系统后端大概两千行 Java前端大概一千五百行 Vue对于刚接触这套技术栈的朋友来说一周到两周的时间完全可以跑通。重点不是把代码堆完而是在这个过程中搞清楚每一层为什么存在、每一条动态 SQL 为什么这么写这才是这类项目真正的价值所在。我做这个项目最大的收获就是到最后能在不看文档的情况下随手画出整个请求链路页面按钮点击、Axios 拦截器、JWT 鉴权、Controller 接收参数、Service 处理事务、MyBatis 拼接 SQL、MySQL 落库每一步都清清楚楚这种“通盘掌握”的感觉才是这个项目最值钱的部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑