前阵子我完整做了一个“专辑鉴赏”方向的网站源码项目,技术栈就是标题里那套:Java SpringBoot做后端,Vue3做前端,MyBatis做数据库映射,MySQL存数据,前后端完全分离。这个项目从0到跑通再到能部署上线,中间踩了不少坑,也沉淀了不少值得写下来的经验。如果你正准备做一个类似的展示+评价类网站,或者想找一份能练手又能落地的前后端分离项目参考,这篇内容是围绕全套源码项目做的完整拆解,从功能设计、表结构、接口实现到前端对接都过一遍,顺便把那些文档里很少提到的坑也一并说清楚。
1. 从“晒唱片”到“品鉴系统”:这个专辑鉴赏项目到底做了什么
1.1 功能地图:三种角色的使用场景
这个系统本质上是一个围绕“音乐专辑”做内容管理和用户互动的网站。你可以在系统里录入一张张专辑,维护专辑封面、发行时间、风格流派、曲目列表,再配上整体赏析文字。普通用户进来可以浏览专辑库、按风格或年代筛选、搜索专辑名和艺人名,点进详情页看完整信息、打分、写评论、收藏到个人清单。管理员在后台负责专辑的增删改查、封面图上传、评论审核,以及用户和基础数据的维护。
一开始我其实想得比较简单,觉得这不就是一个CMS加上评论功能嘛。但真正做起来才发现,专辑鉴赏和普通文章列表完全不是一回事。专辑本身是结构化数据:它有艺人、发行年份、风格标签、曲目、评分、评论、收藏状态,这些数据之间存在大量关联关系。用户端也不只是“看文章”,而是要有“打分—评论—收藏—再看”的完整闭环。所以项目的数据模型比想象中复杂,这也是为什么我后来在表结构上花了很多心思。
1.2 为什么用前后端分离,而不是传统单体模板
以前用模板引擎写这类网站,页面渲染靠服务端,改个样式都要重启应用。这个项目从最开始就决定用前后端分离,原因不是追新,而是三个很实际的点:第一,前端交互太重了。详情页里的评分、折叠评论、筛选联动、收藏按钮状态切换,如果用服务端模板手搓,代码会非常混乱。Vue3的响应式状态管理对这种交互友好的多。第二,后端接口要复用。后台管理页和前端用户页虽然在不同客户端里,但它们操作的专辑数据是同一套,后端只需要提供一套REST接口,两边共用就行。第三,是部署和团队协作的灵活性。前端静态文件扔给Nginx,后端单独跑Java进程,更新前端不用重启后端,排查问题也清晰得多。
技术选型上,SpringBoot提供了自动配置和内置容器,能省掉大量XML配置;MyBatis则让SQL保持在手写可控的状态,什么时候联表、什么时候加索引、什么时候用子查询,我都能精确控制。Vue3的组合式API和Vite构建速度,也让前端的开发体验明显比Vue2时代好。这套组合放到今天依然是中小型管理系统和个人项目里非常成熟稳重的方案。
2. 数据库建模:专辑鉴赏的核心是关系,不是列表
2.1 六张核心表的设计思路
我最终设计了六张核心表:用户表、专辑表、曲目表、评分表、评论表、收藏表。专辑表是最核心的实体,字段包括专辑ID、专辑名、艺人名、封面URL、发行日期、风格流派、专辑简介、平均分、评分人数、创建时间、逻辑删除标记等。曲目表属于专辑的子表,通过专辑ID关联,保存曲目序号、曲目名、时长和试听URL。
用户表单独拿出来,不跟管理员混在一起,因为普通用户和管理员的操作权限差异很大,角色字段用了简单的role整数区分。评论表和收藏表都通过外键关联专辑ID和用户ID,其中收藏表还加了一个type字段,用来区分“想听”和“听过”两个状态。这个字段虽然小,但让用户个人页面的展示维度丰富了不少,实现成本却几乎为零。
建表的时候有一个容易忽略的点:字符集必须明确指定utf8mb4,而不是默认的utf8。原因在于专辑名或评论区经常出现表情符号和不常见字符,utf8在MySQL里最多存3字节,遇到四字节的字符就会报错或者乱码。这个我在一开始就吃了亏,后来统一建库时直接配上,后面再没出过乱码问题。
2.2 评分表为什么要拆出来单独建
很多人第一次做这类系统,习惯在专辑表里直接放一个avg_score字段,每次有人评分就直接改这个平均值。这种设计看起来省事,但隐患很大:你无法知道这个分数是多少人打出来的,无法按用户维度做评分历史,更无法统计评分分布。所以我选择单独建评分表,每次评分都新增一条记录,专辑表里的平均分和评分人数作为冗余字段同步更新。
评分表字段很少,核心是user_id、album_id、score、create_time。值得一提的细节是分数存储用TINYINT而不是DECIMAL。例如前端展示的是十分制带一位小数,后端存储为0到100的整数,展示时再除以10。这样可以避免浮点比较和精度损耗,也方便后续做统计直方图。
这张表还必须在(user_id, album_id)上加联合唯一索引。联合唯一索引的作用是,从数据库底层保证一个用户对同一张专辑只能有一条评分记录。如果用户在应用层先查再插,遇到并发请求时还是可能插入重复数据,唯一索引才是真正的兜底方案。真正的插入冲突处理是在框架层捕获异常后转为“更新评分”逻辑。
2.3 逻辑删除与唯一索引的取舍
专辑和评论这类敏感数据,我没有走物理删除,而是给表加了deleted标记字段。管理员删掉一张专辑,实际上是把数据标记为已删除,用户端查不到它了,但数据还在库里,方便出问题时恢复。所有业务查询都强制带上deleted = 0条件,如果某个列表漏了这个条件,接口就会把已删除的数据返回给前端,所以在写XML映射时,我要求自己每条select都先检查一遍过滤条件。
物理删除不一定每次都不好,但在有评论、收藏、评分这类关联数据的场景下,物理删除会引发连锁反应:删掉专辑后,它的评论、收藏、评分是跟着删还是保留?如果跟着删,用户的收藏记录和历史评分就没了;如果保留,又会变成一堆无主数据。逻辑删除能很好规避这个问题,代价只是每个查询多一个条件而已,完全划算。
3. 后端接口:MyBatis映射与查询性能的实战取舍
3.1 分页查询:手写limit还是用通用插件
专辑列表页、后台管理列表、评论列表都需要分页。我最开始用了MyBatis的分页插件,因为它看起来省事,一个PageHelper.startPage()就能自动拼接count和limit。但用了一段时间发现,这个方案在动态排序时不太好控制,尤其是当排序字段来自前端时,插件帮你拼接SQL的方式让我不太放心。
后来我在XML里手写了分页SQL,参数直接传offset和pageSize。其实也很简单,核心就是一句:
<select id="pageAlbum" resultType="com.example.entity.Album"> SELECT id, album_name, artist_name, cover_url, genre, publish_date, avg_score, rating_count FROM tb_album <where> deleted = 0 <if test="keyword != null and keyword != ''"> AND (album_name LIKE CONCAT('%', #{keyword}, '%') OR artist_name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="genre != null and genre != ''"> AND genre = #{genre} </if> </where> ORDER BY <choose> <when test="sort == 'latest'">create_time DESC</when> <when test="sort == 'hot'">rating_count DESC</when> <when test="sort == 'score'">avg_score DESC, rating_count DESC</when> <otherwise>id DESC</otherwise> </choose> LIMIT #{offset}, #{pageSize} </select>排序字段不是直接拼接前端参数,而是通过<choose>映射成一个固定字符串白名单,这样能有效避免SQL注入。在这个项目里,排序选项就那么几个:最新、最热、最高分。分页插件确实能减少几行代码,但手写SQL让我对最终执行的语句完全可控,尤其是条件多、排序规则复杂时,这种可控性是值得的。
3.2 专辑详情聚合查询的三种写法
详情页需要一次返回基础信息、曲目列表、平均分、评分人数、当前用户的评分状态、收藏状态、最近评论。这里有三种聚合方案。
第一种是为主表调用一次查询,然后曲目、评分状态、收藏状态、评论各查一次,一共五条SQL。优点是代码清晰,缺点是请求耗时高。
第二种是把所有信息塞进一个大SQL里,通过多层嵌套结果集映射成对象图。优点是只查一次库,缺点是SQL和resultMap非常复杂,一旦字段变化调试成本很高。
第三种是折中方案,也是我实际采用的:基础信息和聚合信息分开查。基础信息直接查专辑表;平均分和评分人数直接查专辑表里的冗余字段,不再单独聚合;当前用户的评分和收藏状态用两个简单查询;评论单独做分页查询。这样详情接口实际上只查了两三次数据库,而且每条SQL都简单明了。
真正让我踩坑的是MyBatis的字段映射。数据库字段用下划线命名,Java实体用驼峰命名,如果两者映射对不上,查询结果就会全是null。解决方式是在全局配置文件里开启驼峰映射:
mybatis: configuration: map-underscore-to-camel-case: true开启之后album_name会自动映射到albumName。但嵌套结果集里的复合字段名,比如comments集合里再套一个用户信息,在某些特殊命名下依然要手动指定column和property。这块经验是:能用配置文件解决的就不要手写resultMap;一旦真需要手写resultMap,每一个嵌套字段都要反复核对。
3.3 评分提交的并发与事务处理
提交评分这个操作虽然小,但涉及的数据一致性隐患不少。整个流程是:先检查评分值是否在合法范围内,然后写入评分表记录,接着更新专辑表的平均分和评分人数字段。这三个动作必须在同一个事务里,否则容易出现评分表有记录、专辑平均分没更新,或者反过来数据对不上的情况。
我在Service层直接加了事务注解,代码如下:
@Transactional(rollbackFor = Exception.class) public void rateAlbum(Long userId, Long albumId, Integer score) { // 判断评分范围 if (score == null || score < 0 || score > 100) { throw new IllegalParamException("评分范围不合法"); } try { ratingMapper.insert(new AlbumRating(userId, albumId, score)); } catch (DuplicateKeyException e) { ratingMapper.updateScore(userId, albumId, score); } albumMapper.refreshScore(albumId); }还有一个并发细节容易被忽略:两个用户同时给同一张专辑评分,两次refreshScore都可能先读后写,最后导致平均分被覆盖成错的。为了避免这个问题,刷新平均分的SQL不能写成“查出来算好再更新”,而是直接在数据库层面用一个原子更新:
UPDATE tb_album SET rating_count = rating_count + 1, avg_score = ROUND((avg_score * (rating_count - 1) + #{score}) / rating_count, 1) WHERE id = #{albumId} AND deleted = 0改成这种写法后,即使并发请求同时进来,数据库行锁也能保证每次计算都是基于最新值,不需要在应用层加锁。小型系统里这种原子更新的方案比乐观锁更好写,也更容易读懂。
4. Vue3前端:状态管理、请求封装和交互细节
4.1 登录状态与路由守卫
前端登录成功后,后端返回一个字符串Token和用户基本信息,Token被存进localStorage,用户信息存进全局的响应式状态里。之后每次请求都会在请求头中带上这个Token,后端用拦截器校验。
为什么不用sessionStorage而用localStorage?一个是刷新不丢,一个是关闭浏览器再打开仍能保持登录态。对一些需要经常关浏览器重新打开的用户来说,这个体验舒适度差别很明显。当然,如果做更严格的安全策略,可以改成Token短期有效加刷新Token的方案,但在这个项目里一个有过期时间的Token已经够用。
路由守卫主要集中在三个地方:未登录用户访问个人中心时跳转登录页;已登录用户访问登录页时跳回首页;普通用户访问后台页面时被拦截并提示无权限。Vue3里用router.beforeEach写起来非常顺手,核心逻辑就是取Token,再比对当前路由的meta.requiresAuth和meta.role。
4.2 Axios拦截器与统一错误处理
请求封装我建议单独放在一个utils/request.js里,而不是在每个页面里直接调Axios。统一封装的好处是,Token注入、超时设置、错误提示、401跳转这些逻辑只需要写一遍。我在拦截器里加了请求头注入:
service.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config })响应拦截器里会统一判断HTTP状态码和后端返回的业务码。当捕获到401时,清掉本地Token并跳转登录页;其他错误根据错误码映射成中文提示,用UI库的Message组件弹出。这里有一个小细节:后端返回的错误提示必须统一格式,我统一定义为{ code, message, data },否则前端拦截器每个接口都要单独做判断,代码会非常难维护。
4.3 搜索防抖与筛选面板
专辑列表页有搜索框、风格下拉框、排序下拉框三个筛选维度。如果每次用户输入一个字符就请求一次接口,会对后端造成很多无意义的查询压力。我加了一个防抖函数,用户在停止输入600毫秒后才真正发起搜索请求。
Vue3组合式API写防抖很简洁:
import { ref, watch, onBeforeUnmount } from 'vue' const keyword = ref('') let timer = null watch([keyword, genre, sort], () => { clearTimeout(timer) timer = setTimeout(() => { page.value = 1 fetchAlbumList() }, 600) }) onBeforeUnmount(() => clearTimeout(timer))有一个坑值得提醒:防抖定时器一定要在组件卸载时清理掉,否则用户从列表页跳到详情页再返回,原来组件里的定时器可能还在执行,导致发一次多余的请求。很多小伙伴接手这类代码时会漏掉onBeforeUnmount清理,时间久了就会积累奇怪的多余请求问题。
4.4 图片上传的完整流程
封面图上传是前端对接后端时最容易出问题的一块。我在后台管理页面用UI库的上传组件,选择文件后先走前端预览,再提交给后端接口。后端用MultipartFile接收文件,把文件保存到服务器上一个独立的上传目录,同时在数据库里记录相对于服务器的资源访问路径。
这里的关键经验是:上传目录绝不能放在项目的src/main/resources下。打包成Jar运行后,resources目录在Jar包内部,是无法正常写入和读取的。正确做法是放在一个外部独立目录,比如Linux服务器上的/app/album-upload,然后在Nginx里把一个访问路径指向这个目录。后端向数据库写入的URL字段是/uploads/xxx.jpg,Nginx通过location /uploads/映射到物理目录,这样前端就能直接访问到图片了。
文件命名也必须重新生成,不能直接用用户原本的文件名。我用的是日期时间加随机字符串的方式,避免文件名冲突,也防止特殊字符导致路径解析问题。
5. 本地跑通与部署:版本匹配和踩坑记录
5.1 环境版本清单
这套系统对开发环境的要求并不苛刻,但版本匹配还是要认真核对。后端我用的JDK 17以上和SpringBoot 2.7版本,前端Node 18以上和npm 9,MySQL 8.0。这几个版本组合起来非常稳定。
| 组件 | 版本 | 备注 |
|---|---|---|
| JDK | 17 | 低于17会不匹配部分SpringBoot依赖 |
| SpringBoot | 2.7.x | 成熟稳定,资料多 |
| MyBatis | 3.5.x | 配合SpringBoot起步依赖 |
| MySQL | 8.0 | 使用InnoDB引擎 |
| Vue | 3.x | 组合式API |
| Vite | 4/5 | 构建前端项目 |
| Node | 18及以上 | 低于18可能无法启动Vite |
如果你是第一次在本地跑这类前后端分离项目,建议先启动后端,确认MySQL连接正常,再启动前端。后端启动报错时先看控制台日志里有没有方框样式的乱码,如果有,多半是数据库连接串里的字符编码没配好,加上characterEncoding=utf8和serverTimezone=Asia/Shanghai一般就好了。
5.2 启动过程与常见报错
启动阶段我遇到最多的三类问题。第一类是端口被占用。后端默认8080,本地如果跑过其他程序就经常冲突。解决办法很简单:换一个端口,或者在运行参数里动态指定。前端Vite默认5173端口,如果冲突它会自动换一个,但要注意换掉之后所有接口请求地址也要对得上。
第二类是数据库连接失败。这个错误提示通常很直接,但很多人会忽略MySQL 8的驱动变化。MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver,如果沿用旧版的驱动类名会直接启动失败。同时连接串里要记得开启SSL安全选项,本地开发一般用useSSL=false避免不必要的警告。
第三类是前端启动后接口全部404。这个几乎每次都会出现,原因是前端开发环境访问的接口地址指向了后端地址,但没有配置跨域。我的做法是在Vite的配置文件里加开发代理,前端请求统一走/api前缀,由Vite代理转发到后端的http://localhost:8080。这样浏览器里看到的请求是同源的,不需要后端单独开放CORS跨域配置,也顺手把跨域这个很烦的问题在生产环境用Nginx统一解决掉了。
5.3 Nginx反向代理与跨域
生产环境部署时,前端npm run build生成静态文件,放到Nginx的静态目录下。Nginx同时承担两个职责:一是托管前端静态资源,二是反向代理后端接口和图片上传目录。这里最关键的配置是前端路由的try_files,否则使用history模式的Vue项目在刷新详情页时会返回404:
server { listen 80; server_name example.com; root /usr/share/nginx/html; index index.html; location / { 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; } location /uploads/ { alias /app/album-upload/; } }在/api/的代理配置里,如果proxy_pass的URL结尾带不带斜杠,转发效果是完全不同的。带斜杠会去掉/api前缀直接转发,不带斜杠则保留完整路径。我建议后端接口统一以/api开头,Nginx直接透传还是剥离前缀,要在上线前就确定好,免得正式环境一直出现404却找不到原因。
6. 这套系统再往下能扩什么
6.1 评分分布与数据看板
现有的评分数据已经足够支撑一个统计维度了。每张专辑的评分都存了原始值,理论上可以做评分分布直方图,比如从1星到5星各占多少人。这个看板用ECharts就能实现,后端写一个简单的分组统计SQL,按分数区间聚合返回给前端。对于专辑运营方来说,评分分布比平均分更有洞察力:平均分看着7.2,但如果分布是两极分化,说明这张专辑“喜欢的很喜欢,讨厌的很讨厌”,跟清一色的中庸评价完全是两种鉴赏感受。
6.2 从静态列表走向推荐
现在专辑列表的排序只能是时间、评分、热度。有了用户收藏和评分数据之后,就可以做简单的推荐策略。最轻量的是“相似风格推荐”,在专辑详情页查同风格、同年代的其他专辑;进阶一点可以做“协同过滤”,找到和你打分相似的其他用户,把你没打过分的专辑推荐给你。这类算法在中小型项目里用不到复杂的机器学习框架,纯SQL和基础的推荐公式就能跑得动。
6.3 创建自己的“鉴赏社区”
现在的评论模块是单向的,用户只能对专辑发表评论,用户之间没有互动关系。如果再往下走,可以做评论点赞、用户互相关注、个人主页展示“我听过的专辑墙”等功能。这些方向在数据结构上都不难扩展:点赞加一张关联表,关注加一张关注表,个人主页只是把已有的评分表、收藏表、评论表按用户维度重新聚合而已。增量成本不大,但对系统的完整度提升非常明显。
最后再分享一个我自己的部署体会。后续版本迭代时,建议把上传目录在Nginx里单独暴露出来,不要让Java进程去处理静态文件的响应。图片这种资源让Nginx直接返回,Java应用只处理JSON接口,性能会更稳。像这种前后端分离的项目,表面上难点在业务代码,实际上部署边界、静态资源路径、接口前缀这些“基础设施”才是决定你项目能不能顺利跑起来的关键。把这一层理清了,后面的功能开发就会顺很多。