记得去年帮一个学员梳理毕设选题,学院给了十几类可选项,他纠结了半天,最后选了SpringBoot+Vue的前后端分离方向。这个选择在现在的课设和毕设里确实太常见了——刷开招聘软件,Java后端配Vue前端的岗位需求也多,学完至少能跟真实工作流程对上。但选对技术栈不代表能把项目做明白,尤其是专辑鉴赏网站这类偏内容管理的业务,看起来简单,真要动手把Java+MySQL、前端路由、鉴权、部署这套链路从头跑到尾,值得讲清楚的细节相当多。这篇文章就以我做的一个专辑鉴赏网站管理平台为例,从数据库设计、后端接口、前端页面、上线部署四个环节,把每个关键取舍和踩过的坑都过一遍。不管你是要拿它做毕业设计、课程设计,还是单纯想学前后端分离的完整流程,这篇应该都能帮上忙。
1. 为什么选 SpringBoot+Vue 做专辑鉴赏平台
1.1 前后端分离在毕设场景下的实际优势
聊到SpringBoot+Vue,很多人第一反应是"网上教程多、模板好找"。但这套技术栈真正适合做专辑鉴赏平台,不光是教程多的问题,而是它的分离结构刚好跟内容管理类业务的开发节奏对得上。
我以前也犹豫过要不要用单体模板引擎,比如JSP或Thymeleaf直接渲染页面。后来把需求完整跑一遍才发现,前后端分离让后端专心处理数据和权限,前端专心处理展示和交互,两边并行开发互不阻塞。对于一个人做毕设来说,这种隔离反而让代码更好维护,也更容易讲清楚。
另外评审老师看项目,最关心的其实就几件事:系统里有几个角色、数据流怎么走、亮点功能怎么实现。前后端分离在这几个问题上天然占优——登录鉴权、接口鉴权、路由守卫、跨域处理,随便一个都能展开讲好几分钟。相比传统模板引擎把页面和后端揉在一起,前后端分离出的页面效果也更接近真实产品,演示的时候观感完全不一样。
1.2 专辑鉴赏的业务模型到底复杂在哪
专辑鉴赏的核心实体其实不复杂,无非是专辑、歌曲、歌手、用户、评论、收藏。但这里有一个容易被低估的点:专辑和歌曲之间是一对多关系,歌手和专辑之间是多对多关系,评论又要挂到专辑或歌曲上,同时还要携带用户信息。这种实体关系不深,但足够把连接查询、分组统计、多表分页全部练一遍。
对比那些纯增删改查的"学生信息管理"项目,专辑鉴赏平台在业务上多了一层内容感——用户会去搜索专辑、查看详情、打星写评论、收藏到自己的列表。这些交互最终都会落到接口上,后端需要处理的不只是CRUD,还有参数校验、状态变更、数据统计、异常兜底。对课设或毕设来说,这个业务复杂度正好卡在"能按工期做完"和"有东西可讲"的平衡点上,既不会因为太简单显得敷衍,也不会因为太庞大做不完。
1.3 技术范围的克制:不碰微服务,不硬加中间件
很多同学做项目容易犯一个毛病:为了显得"高级",硬往项目里塞微服务、消息队列、注册中心。我见过有人给一个管理平台配了两个注册中心加三个服务,最后答辩被问得说不出个所以然。专辑鉴赏这种量级的业务,老老实实单应用就好,把单应用的每一层写好,本身就已经覆盖了绝大多数岗位要求的核心技能。
我认为合理的技术范围是:SpringBoot负责后端接口,MyBatis-Plus负责数据访问,MySQL负责存储,Vue加Element UI负责页面,JWT做登录态,封面文件走本地存储。这套组合性能足够、维护简单、知识点覆盖主流要求。如果想让项目更有含金量,可以在验证码存储、热门专辑缓存这些局部点引入Redis,而不是硬上整套分布式体系。技术范围克制,不等于项目没亮点,关键是每个引入的组件都要能讲清楚"为什么需要它"。
2. 数据库设计:专辑鉴赏业务的第一块地基
2.1 核心表结构:从专辑实体出发
数据库设计里有个常见误区,一上来就想着把所有字段都塞进一张大表。我的习惯是先想清楚核心业务实体,再围绕关系去拆表。这个项目里我建议先建album(专辑表)、song(歌曲表)、user(用户表)三张核心表,然后围绕评论和收藏延伸出comment表和favorite表。
album表的核心字段大致包括:id、album_name、artist_id、release_date、album_cover、album_desc、like_count、total_songs、create_time。song表至少要有:id、song_name、album_id、duration、track_number、audio_url。这里有个容易被忽略的设计判断:artist(歌手)要不要单独建表?如果专辑页要展示歌手信息和歌手专辑列表,我强烈建议单独建artist表,用artist_id关联。虽然标题里只提了专辑,但专辑鉴赏网站天然离不开歌手维度,单独建表之后写查询逻辑反而更清爽。
2.2 用户、评论、收藏的关系设计
用户表设计不算复杂:id、username、password、nickname、avatar、role、create_time。密码不要用明文存储,至少用BCrypt加密。role字段用来区分管理员和普通用户,我用了简单的字符串类型,管理员为admin,普通用户为user,这样拦截器判断角色时不需要复杂的表关联。
评论表需要记录user_id、target_type、target_id。target_type用来区分这条评论是针对专辑还是针对歌曲,用一个字段区分评论对象,能少建一张表,查询时稍微麻烦一点但完全可控。收藏表favorite更简单:id、user_id、album_id、create_time,再加一个唯一索引(user_id, album_id),防止同一用户重复收藏同一个专辑。这个唯一索引属于建表时最容易漏、但答辩时又最喜欢被问到的细节,加上之后代码里做幂等判断就非常放心。
2.3 建表SQL与实际字段设计的几个坑
以下是我在项目里实际用过的核心建表SQL(MySQL 8.0),可以直接参考:
CREATE TABLE `album` ( `id` bigint NOT NULL AUTO_INCREMENT, `album_name` varchar(128) NOT NULL COMMENT '专辑名称', `artist_id` bigint NOT NULL COMMENT '歌手ID', `release_date` date DEFAULT NULL COMMENT '发行日期', `album_cover` varchar(255) DEFAULT NULL COMMENT '封面路径', `album_desc` text COMMENT '专辑简介', `like_count` int DEFAULT 0 COMMENT '点赞数', `total_songs` int DEFAULT 0 COMMENT '歌曲数量', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, `update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_artist` (`artist_id`), KEY `idx_release` (`release_date`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这张表里有三个细节值得注意。
第一,total_songs字段不是从song表实时count出来的,而是在新增或删除歌曲时用事务做增量更新。这样专辑列表页不需要每次都写聚合查询,列表接口的响应速度会明显更快,代价是维护时要记得同步更新这个冗余字段。
第二,索引建在artist_id和release_date上,因为列表页最常见的筛选就是"按歌手看"和"按年份看"。排序和筛选能走索引,数据量大了之后不会很慢。
第三,所有表统一用utf8mb4字符集。评论里可能出现特殊字符、表情符号,utf8mb4才能完整存下来。这个坑我见过不少人踩过:建表时没指定字符集,跑起来之后发现中文乱码,再想改表结构就要复制数据,非常痛苦。
song表和comment表的设计思路类似。song表通过album_id关联专辑,comment表用target_type加target_id做多态关联。实际开发中多态关联会让物理外键不好写,所以我对评论表不建物理外键,只在查询时用逻辑关联,加普通索引保证效率。这样既能避免删除专辑时被外键约束卡死,也不影响按用户查评论列表。
注意:MySQL 5.7和8.0在时区处理上有差异,JDBC连接串里一定要加上 serverTimezone=Asia/Shanghai,否则日期字段读写会出现8小时偏差。这种问题排查起来非常隐蔽,因为看起来功能都正常,就是时间对不上。
3. 后端接口链路:登录鉴权、专辑列表与封面上传
3.1 登录接口与JWT鉴权流程
后端第一个要落地的是登录接口。流程不复杂:用户提交用户名和密码,后端查库、用BCrypt校验密码、生成JWT返回给前端。前端把token存到localStorage,后续请求在请求头带上Authorization字段。后端用一个拦截器统一校验token,未登录的请求直接返回401,前端拿到401后跳转到登录页。
JWT我用的是jjwt库,生成token的核心代码大致是这样:
private String generateToken(User user) { long expiration = 7L * 24 * 60 * 60 * 1000; // 7天 return Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("role", user.getRole()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + expiration)) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }拦截器里只做JWT解析和校验,不查数据库,这是JWT无状态设计的核心理念。但无状态也意味着token无法主动失效,如果以后要做"修改密码后踢掉其他设备"这类需求,就需要额外引入token黑名单机制,复杂度会上升。对这个平台来说,7天过期已经够用,没必要为了一个边界需求把架构做复杂。
角色权限方面,我采用了一个相对简单的策略:普通用户和管理员都走同一套登录接口,后台管理接口用注解标注只能由管理员访问。答辩时如果被问"如何控制权限",这个点可以直接讲明白:登录接口统一、拦截器统一鉴权、方法级注解做细分权限,三层各司其职。
这里还要补一句:密码加密一定不能省。用Spring Security自带的BCryptPasswordEncoder就能做,存到数据库的是带盐的哈希值。哪怕项目里没有完整的Spring Security安全框架,单独引入spring-security-crypto这个依赖也很轻量,不至于把安全问题拖到答辩时被问住。
3.2 专辑分页查询:多条件筛选的接口设计
列表页是专辑鉴赏平台的核心页面,接口设计上我同时支持了分页、关键词搜索、按年份筛选、按点赞数排序。后端用MyBatis-Plus的Page对象配合LambdaQueryWrapper实现:
public Page<AlbumVO> pageAlbums(int page, int size, String keyword, Integer year, String orderBy) { LambdaQueryWrapper<Album> wrapper = new LambdaQueryWrapper<>(); if (StringUtils.hasText(keyword)) { wrapper.like(Album::getAlbumName, keyword); } if (year != null) { wrapper.eq(Album::getReleaseDate, year + "-01-01"); } wrapper.orderByDesc("like_count".equals(orderBy) ? Album::getLikeCount : Album::getReleaseDate); Page<Album> pageResult = albumMapper.selectPage(new Page<>(page, size), wrapper); return convertToVO(pageResult); }分页里最常见的坑是前端传的页码到底从1开始还是从0开始。我的约定是page从1开始,size默认10,最大不超过50。后端必须做参数校验,size传99或者负数,直接返回参数错误,不能让异常一路打到前端。
排序字段这里有个安全细节:不能直接把前端传来的字段名拼到SQL里。要么走MyBatis-Plus的lambda字段引用,要么做一个字段白名单映射。字符串拼SQL的后果是SQL注入,哪怕在自己做的课设项目里,这种最低级的安全漏洞也应该杜绝。
列表接口返回的应该是VO而不是数据库实体。Album实体里可能有createTime、updateTime这类页面用不到的字段,还有albumDesc这种又长又占带宽的文本字段。VO只用包含页面需要的字段,再额外带上歌手名、歌曲数量,这样前端拿到的JSON结构清爽,加载也更快。这个设计在答辩时同样是可以重点讲的优化点。
3.3 封面文件上传:本地存储的路径与静态资源映射
专辑封面上传是管理端操作。前端用Element UI的Upload组件,后端接收MultipartFile,保存到本地目录,再返回可访问的URL。代码不复杂,但有两个坑值得重点记录。
第一个坑是存储路径不要写死绝对路径,项目会换机器,服务器和开发机的目录结构完全不一样。正确做法是把路径抽到配置文件里,比如application.yml里放一个upload.dir配置项,部署时改配置即可。
第二个坑是SpringBoot默认不会把本地目录映射成可访问的静态资源,需要在配置类里手动添加映射:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { String uploadPath = uploadDir + File.separator; registry.addResourceHandler("/uploads/**") .addResourceHandler("file:" + uploadPath); } }上传文件的后缀校验也必须有,只允许jpg、png、jpeg这些图片类型。只靠扩展名判断并不可靠,因为文件可以改名骗过检查,最好再读文件头判断真实类型。文件大小也必须限制,SpringBoot默认限制单文件1MB,我实际调到了5MB,超出限制后端返回友好提示,不然用户在前端只看到一句泛泛的报错,根本不知道是文件太大还是路径写错。
统一返回结构也是这个阶段一定要定的事。我用的结构是{code, message, data},code为0表示成功,非0携带具体错误码。这个约定别看简单,它让前端axios拦截器只用判断一次code就能统一处理所有业务异常,避免了"接口返回200但数据其实是错误提示"的尴尬情况。
4. 前端 Vue 部分:路由、页面与状态设计
4.1 前端项目结构与请求封装
前端我用的是Vue 2加Element UI,项目通过vue-cli创建。目录结构上,src下分成views、components、api、router、store几个包。api包里每个模块对应一个后端Controller,比如album.js、user.js、comment.js,页面里不直接写axios调用,而是调用封装好的方法。这样后端接口路径一旦要调整,只需要改api包里的一个文件,不会出现全局搜索替换的窘境。
axios实例的封装是前端工程质量的分水岭。核心是两层拦截器:请求拦截器自动带上token,响应拦截器统一处理401跳转登录页和业务错误码。代码大致是这样:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } return Promise.reject(error) } )这里有个特别重要的设计决定:baseURL用"/api"而不是完整的后端地址。好处是开发时走代理转发,部署时走Nginx反向代理,前端代码不需要因为环境变化改任何一行。如果直接把localhost:8080写死,换一台机器或部署上线时还要改代码重新打包,这就是典型的没经验才会踩的坑。
路由守卫更是必做项。管理端页面(新增专辑、用户管理)在路由meta里标记admin: true,前端路由守卫判断用户角色,普通用户访问管理页面直接重定向回首页。这个逻辑解决的是页面级权限控制,但后端接口仍然要再做一次校验,不能只依赖前端隐藏按钮。
4.2 专辑列表与详情页的数据对接
专辑列表页我用卡片形式展示:封面、专辑名、歌手、年份、点赞数,每张卡片点击跳转详情页。卡片是一种非常契合鉴赏类业务的展示形式,一屏能同时看到多张专辑封面,视觉上比表格好很多。
详情页拿到专辑id后调后端接口,获取专辑基本信息、歌曲列表、评论区。开发时有个体验问题值得注意:列表页和详情页会共用不少字段,但接口要分开设计。列表接口只返回封面、名称、歌手名、点赞数,详情接口才返回完整的专辑简介、发行日期、歌曲列表和评论分页数据。