拓冰建站拓冰建站
首页 / 资讯中心 / 正文

SpringBoot+Vue3+MyBatis动漫网站系统:从表结构设计到前后端分离部署全解析

做个人项目或者交毕业设计的时候“动漫网站”这类带内容管理属性的系统一直是很多人的首选业务关系不复杂但又有用户、内容、评论、播放记录这些经典模块用一套前后端分离的架构做出来既能展示技术栈的完整度又足够贴近真实产品。这次我做的这套基于 Java SpringBoot Vue3 MyBatis MySQL 的国产动漫网站系统就是把这类项目从零到部署跑通了一遍。本文会把我的梳理过程、核心代码组织方式、前后端对接的细节以及上线前后踩过的几个坑都展开写清楚适合想拿现成源码二次开发、或者正在规划同类型全栈项目的朋友参考。前后端分离说起来简单真正动手的时候最容易卡住的往往不是某个 API 写不出来而是“表怎么设计、接口怎么分层、前端的请求封装怎么和后端对齐”这一类整体性问题。下面我按自己的实际操作顺序来写不绕弯子。1. 需求边界与技术选型这套动漫站究竟长什么样1.1 为什么锁定 SpringBoot Vue3 MyBatis 这套组合先把选型逻辑说清楚免得有人问“为什么不直接用若依、为什么不把 MyBatis 换成 MyBatis-Plus”。后端用 SpringBoot核心原因是它的起步成本最低。内嵌 Tomcat打包就是可执行 Jar部署时一条java -jar就能拉起来不需要额外配服务器。SpringBoot 的 Starter 机制把数据源、JSON 序列化、参数校验这些杂事都自动配置好了我能把精力集中放在业务接口上。对比传统的 SSM 手写配置SpringBoot 起码省掉两三个小时的 XML 配置文件调错时间。持久层选 MyBatis 而不是 JPA我是有意为之。动漫站的查询场景非常典型番剧列表要做多表联查按分类筛选、按关键词搜索、按热度排序这些 SQL 在 MyBatis 里可以写得很精确而且运行时能看到日志里实际执行的 SQL对排查慢查询很有帮助。对正在学 Java 的人来说MyBatis 也是面试和工作中出现频率极高的技术点用这个项目练手顺便就把动态 SQL、缓存、分页插件这些知识点全部覆盖了。前端选 Vue3 而不选 Vue2是因为这已经不是“尝鲜”阶段了。Vite 的冷启动速度比 webpack dev server 快一个量级组合式 API 写业务逻辑比 Options API 更聚合。评论列表、播放页的进度记录、追番状态切换这类功能用ref和computed维护起来非常顺手。1.2 功能清单一个动漫网站该有的模块这套系统我按“前台展示 后台管理”两个端来设计功能。前台提供给普通用户用户注册、登录、个人资料维护番剧首页轮播图推荐、分类入口、最新更新、热门排行榜番剧列表页按分类筛选、关键词搜索、分页浏览番剧详情页封面、简介、声优/制作公司信息、评分展示播放页视频播放器、当前选集、播放历史进度记录追番收藏收藏/取消收藏、我的追番表评论区发表评论、查看评论列表后台提供给管理员番剧管理新增、编辑、上下架番剧维护剧集分类管理增删改动漫分类用户管理查看用户列表、禁用账号评论管理删除违规评论轮播图管理配置首页 Banner这套功能覆盖了一个典型内容型网站的所有基础要素把用户体系、内容体系、互动体系串成了一条完整链路。1.3 工程目录怎么组织实际开发的时候我建议用“前后端分离的目录隔离”前端和后端两个独立目录各自维护。后端的包结构com.example.anime ├── config // 跨域、拦截器、数据源等配置类 ├── common // 统一返回体、全局异常、工具类 ├── controller // 接收请求只做参数校验和路由转发 ├── service // 业务逻辑层接口实现类 ├── mapper // MyBatis 的 Mapper 接口 ├── entity // 数据库表对应的实体类 └── dto // 前端入参/出参对象避免实体直接暴露controller 这一层尽量薄业务逻辑全部下沉到 service。前端目录我用 Vite 初始化核心结构是src ├── api // 按模块封装的请求方法 ├── router // 路由表 ├── store // Pinia 状态管理 ├── views // 页面组件 ├── components // 通用组件 └── utils // 请求封装、工具函数这个结构不特殊但很稳定所有功能都能找到固定的归属位置后续要加“弹幕系统”这类的新模块直接新增一组 controller、service、mapper 和对应前端页面就行。2. 数据库与 MyBatis 层整个项目的地基要这样打2.1 表结构设计六个核心表动漫网站的数据库设计不需要特别复杂的范式但要能撑起业务逻辑。我实际设计的表如下表名主要字段作用userid, username, password, avatar, role, status, create_time用户账号与角色categoryid, name, sort_order番剧分类anime_infoid, title, cover, description, category_id, status, rating, update_time番剧基本信息episodeid, anime_id, episode_no, video_url, title某个番剧下的剧集favoriteid, user_id, anime_id, create_time收藏/追番记录commentid, anime_id, user_id, content, create_time, status评论数据play_historyid, user_id, anime_id, episode_id, progress, update_time播放进度记录几点设计心得用户名直接建唯一索引因为登录时要按用户名查询。anime_info.category_id建普通索引前台列表页按分类筛选很频繁。comment.anime_id也要建索引番剧详情页要一次性拉出该番剧的评论列表。play_history用user_id anime_id做唯一索引做到一个用户对一部番剧只保留一条进度记录播放新剧集时覆盖旧进度避免历史表无限膨胀。2.2 MyBatis 动态 SQL列表页查询的精髓番剧列表页的筛选条件是动态的用户可能只选了分类也可能同时输入了关键词。如果用 Java 代码去拼 SQL 字符串很容易出现引号拼接错误和 SQL 注入风险。MyBatis 的where和if标签就是为这种场景设计的select idselectAnimePage resultTypecom.example.anime.dto.AnimeVO SELECT a.id, a.title, a.cover, a.rating, c.name AS categoryName FROM anime_info a LEFT JOIN category c ON a.category_id c.id where if testcategoryId ! null AND a.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (a.title LIKE CONCAT(%, #{keyword}, %) OR a.description LIKE CONCAT(%, #{keyword}, %)) /if if teststatus ! null AND a.status #{status} /if /where ORDER BY a.update_time DESC /select这里有个细节值得说一下模糊查询不要用%${keyword}%这种写法${}是字符串直接替换用户输入 or 11这类内容就会改变 SQL 语义。用#{keyword}配合CONCAT拼接MyBatis 会走预编译的?占位符从根上避免注入问题。和分页插件配合的时候多表查询的resultType建议用 DTO 而不是实体类因为查出来的结果是“番剧 分类名”的混合结构实体类里面并没有categoryName这个字段。这个习惯能少写很多临时实体。2.3 分页插件 PageHelper 的正确打开方式首页和列表页都需要分页我用的是 PageHelper。引入依赖之后用法就是在 Mapper 查询方法执行前一行调用静态方法PageHelper.startPage(pageNum, pageSize); ListAnimeVO list animeMapper.selectAnimePage(categoryId, keyword); PageInfoAnimeVO pageInfo new PageInfo(list);PageHelper.startPage底层用的是 ThreadLocal它会把分页参数绑定到当前线程上然后拦截下一次执行的查询 SQL自动拼上LIMIT并执行COUNT。返回值count是查询结果总数list就是当前页的数据。前端拿到pageInfo后只需要把total、list、pageNum、pageSize传给表格组件就能完成分页展示。这里有两个使用禁忌第一startPage之后必须紧跟一条 Mapper 查询语句中间不能再调用任何其它 SQL 查询否则分页参数会作用到错误的查询上。第二自定义的countSQL 如果 Mapper 方法里本来就写了子查询PageHelper 生成的 count 语句可能跑得很慢这时可以给 Mapper 方法加一个同名的方法名_COUNT查询比如selectAnimePage_COUNT把 count 逻辑单独优化。2.4 MyBatis 缓存怎么用才不出问题MyBatis 自带一级缓存和二级缓存。一级缓存是SqlSession级别的同一个 SqlSession 内查询同样的语句会走缓存。Spring Boot 中每个请求默认会新建 SqlSession请求结束就销毁所以一级缓存在常规 Web 项目里能起到的加速效果很有限不用太指望它。二级缓存是 Mapper 级别的可以在多个 SqlSession 之间共享。但我个人建议动漫站这类业务不要轻易开启 MyBatis 二级缓存。原因很实际二级缓存默认是本地内存缓存注释表、收藏表这类数据一变缓存里旧数据不会自动失效很容易出现“前台番剧下架了列表页还是显示可观看”的尴尬局面。等用户量真的大到需要缓存的时候直接上 Redis 更可控缓存粒度、过期时间、缓存预热都是自己定的不会再被 MyBatis 的内部机制绑架。提示如果为了应付面试或者确实要演示二级缓存可以给改动频率极低的category分类表开启二级缓存并让实体类实现Serializable接口。其它读写频繁的表老老实实每次都查库。3. 后端接口、登录鉴权与安全防护核心代码往哪放3.1 RESTful 接口设计风格接口统一以/api开头按资源复数列出几个典型的方法路径功能POST/api/user/login用户登录POST/api/user/register用户注册GET/api/anime/page?pageNum1pageSize12番剧分页查询GET/api/anime/{id}番剧详情GET/api/anime/{id}/episodes某番剧的剧集列表POST/api/favorite收藏番剧GET/api/comment/list?animeId1评论列表GET/api/anime/rank热门排行榜后端所有接口返回统一的结构{ code: 200, message: success, data: {} }前端 axios 响应拦截器只看code字段做逻辑判断不需要每次都对 HTTP 状态码单独处理代码会干净很多。code为 401 时统一跳登录页code为 500 时统一弹出错误提示。3.2 JWT 登录鉴权的完整链路用户登录成功后后端生成一个 JWT Token 返回给前端前端存到localStorage之后每个请求都在请求头带上Authorization: Bearer token。后端用一个拦截器统一做鉴权Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录注册接口和静态资源 String uri request.getRequestURI(); if (uri.contains(/user/login) || uri.contains(/user/register)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } // 解析 token失败则 401成功则把 userId 放入 request 上下文 return true; } }这个方案简洁有效但有一个点必须提JWT 是无状态的Token 一旦签发在有效期内无法主动注销。如果要做“管理员强制下线用户”这类功能光靠 JWT 做不到。这类系统里我建议把 Token 有效期设短一点比如 2 小时同时前端用 axios 拦截器检测 401 后自动跳转登录页让用户重新登录获取新 Token。对动漫网站这个量级来说这个方案完全够用。3.3 全局异常处理和参数校验实际跑起来之后会发现真正影响开发效率的不是业务代码写不写得出来而是异常处理写得规不规范。我在项目里做了两层兜底RestControllerAdvice全局异常处理器统一捕获业务异常、参数校验异常、兜底Exceptioncontroller 层入参用ValidatedNotBlank这类注解做声明式校验这样前端传参出错时返回的是{code: 400, message: 用户名不能为空}这种明确提示而不是满屏的异常堆栈。3.4 文件上传与静态资源访问番剧封面、轮播图都是图片文件上传接口用 SpringBoot 的MultipartFile接收即可。图片保存位置我建议放在项目外部的 /data/anime/upload目录不要放进 Jar 包内部。Jar 重新部署会把包内资源整个覆盖上传的图片会丢。访问静态资源时开发阶段用 SpringBoot 的静态资源映射生产环境则交给 Nginx 直接映射location /upload/ { alias /data/anime/upload/; }提示如果后面要接对象存储把保存逻辑抽成一个FileStorageService接口本地实现和 MinIO 实现互相切换业务代码不用动。3.5 安全防护XSS 和 SQL 注入除了前面提的 MyBatis 预编译防 SQL 注入另一个容易被忽略的是 XSS 攻击。评论区功能天然接受用户输入的 HTML 内容如果用户提交一段scriptalert(xss)/script存进数据库其它用户加载评论时这段脚本就会执行。处理方式不复杂前端在提交评论内容时过滤script标签后端再写一个全局过滤器对请求参数中的敏感标签做转义处理。后端过滤尤其重要因为不能信任任何客户端传上来的数据。我这套项目里写了一个XssHttpServletRequestWrapper重写getParameter方法把、、单引号等字符转义这样评论、搜索词、用户资料全都被统一净化了。4. Vue3 前端与后端对接从 axios 封装到播放页4.1 前端工程初始化的几个选择Vue3 项目我直接用 Vite 创建然后手动加入 Vue Router、Pinia、Axios、Element Plus、SCSS。没必要用某个全家桶脚手架把依赖一股脑装齐自己加依赖的过程反而能搞清楚每个库是干嘛用的。Element Plus 按需引入可以显著减小打包体积但配置相对繁琐。个人项目和毕设这个规模直接全量引入问题不大npm run build出来约 1MB 左右的体积在没有严格性能要求的场景下可以接受。真正要在意的是 SCSS 的全局变量文件定好几组主题色、间距变量换肤和统一风格会省很多事。4.2 axios 封装统一处理 token、错误、加载状态前端和后端的沟通全靠 axios封装得好不好直接影响联调效率。我的封装思路是const service axios.create({ baseURL: /api, timeout: 15000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) 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.message) return Promise.reject(new Error(res.message)) }, error { ElMessage.error(网络异常请稍后重试) return Promise.reject(error) } )这里重点讲一下baseURL为什么设成/api。开发环境 Vite 会开启反向代理把/api开头的请求转发到http://localhost:8080生产环境 Nginx 也做同样的转发。这样前端代码里不需要写死后端地址换环境只需要改代理配置不用改代码。跨域问题在生产环境被 Nginx 反向代理天然规避掉了因为浏览器只看到同源的https://你的域名/api真正跨域发生在 Nginx 和后端之间浏览器不参与。4.3 路由守卫与动态标题路由方面主要分“游客可见”和“需登录可见”两类。我的做法是在路由元信息里标记requiresAuth: true然后在全局前置守卫里判断router.beforeEach((to, from, next) { document.title to.meta.title ? ${to.meta.title} - 动漫之家 : 动漫之家 if (to.meta.requiresAuth !localStorage.getItem(token)) { next(/login) return } next() })页面标题用路由 meta 动态设置这个细节看着小但对内容型网站来说很影响体验。用户在浏览器标签页能直观看到当前在哪个番剧页面。4.4 播放页实现播放器、选集、进度记录播放页是动漫站的核心页面逻辑链路相对长路由参数带着animeId页面加载时并发请求番剧详情和剧集列表默认选中第一集或上次看过的剧集播放器切集时向后端提交播放进度用户再次进入该番剧时根据进度记录直接定位到上次观看的剧集播放器我用的 DPlayer 的 Vue3 封装版本支持播放进度记忆、倍速播放UI 也比较适合动漫场景。视频地址直接取接口返回的videoUrl字段生产环境这段地址可能是 CDN 签名 URL 或者 OSS 的临时地址。播放进度上报的接口要设计得轻量切集时、暂停时各上报一次即可不需要每秒钟都发请求。后端收到进度后更新play_history表下次进入详情页时前端拉取进度数据直接跳转到对应秒数。4.5 联调阶段容易踩的对接问题前后端分离项目的联调阶段最容易出问题的其实是“数据格式不一致”而不是接口跑不通。典型例子Java 后端返回的Long类型 ID 传到前端当 ID 超过 JavaScript 安全整数范围时精度会丢失。MySQL 的bigint自增主键很容易就超过2^53前端拿到的 ID 最后几位会变成 0。解决方式有两种要么把主键 ID 序列化为字符串返回要么全部用String类型接收。我推荐前者在实体类 ID 字段上统一加JsonSerialize(using ToStringSerializer.class)一劳永逸。时间格式也是联调老问题。默认情况下 Java 返回的是2025-06-01T12:30:00这种 ISO 格式前端展示时经常显示成 “T” 字符串。统一在配置里指定spring.jackson.date-formatyyyy-MM-dd HH:mm:ss前后端就按这个格式解析。5. 上线部署与二次开发一份能照着操作的清单5.1 部署架构整体架构就是一台 Linux 服务器搞定Nginx 部署前端静态文件 反向代理/api后端 Jar 运行在服务器本地 8080 端口MySQL 数据库也部署在同一台机器上。系统初期流量不大时这个架构最省成本一台 2 核 4G 的云服务器足够跑起来。5.2 后端构建与启动顺序后端打包命令很简单mvn clean package -DskipTests产物是一个可执行 Jar。运行时我习惯用外部配置文件覆盖包内配置比如数据库密码这类信息不应该打进包里java -jar anime-server.jar --spring.config.additional-location/data/config/application.yml启动后先检查端口是否正常监听再查看启动日志确认没有报错。用nohup后台运行可以但更推荐用systemd管理进程这样服务器重启后后端服务会自动恢复日志管理也更规范。5.3 前端构建与 Nginx 配置要点前端构建npm run build产物在dist目录上传到服务器的/data/anime-web目录。Nginx 的配置有两个关键点。第一是 History 路由的回退Vue Router 默认用的是history模式如果不配置用户在详情页刷新就直接 404location / { root /data/anime-web; index index.html; try_files $uri $uri/ /index.html; }第二是/api反向代理指向后端服务location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }提示如果你下载的源码里前端用的还是 Vue 2 的 hash 路由就少一个刷新 404 问题但 URL 会带#。升级到 history 模式时一定要记得配套配置try_files。5.4 MySQL 初始化建库建表不需要手动执行一堆 SQL项目准备好初始化 SQL 文件直接导入即可mysql -u root -p anime_db anime_db.sql连接配置里有两个容易出问题的点。一个是字符集characterEncodingutf8和characterSetResultsutf8必须指定否则中文容易乱码。另一个是时区serverTimezoneAsia/Shanghai必须显式配置否则默认按服务器本地时区解析可能导致时间字段差 8 小时。5.5 源码拿到手之后的二次开发建议如果你手头拿到的是编译好的 Jar 包而不是源码我不建议花大把时间去反编译。反编译出来的代码结构会严重失真变量名被打乱注释全部丢失维护成本极高。正确姿势是先跑起来看功能再按功能需求对照网上开源项目的实现逻辑自己写。如果拿到的是真源码二次开发从这几个位置入手新增番剧字段改anime_info表、实体类、Mapper XML、详情页组件新增评论点赞新建comment_like表、增加点赞接口、前端按钮绑定状态接入第三方弹幕播放页集成弹幕 SDK服务端只需提供弹幕上报和拉取接口6. 踩坑实录三个让我加班到深夜的问题6.1 Nginx 刷新 404History 路由的经典坑问题现象前端部署完成后访问首页正常但进入番剧详情页后按 F5 刷新页面直接显示 404。排查过程先看 Nginx 错误日志发现请求的路径是/anime/1而服务器上根本没有这个文件路径于是直接返回 404。再去查 Vue Router 的配置确认用的是createWebHistory。这时就意识到是 SPA 的路由回退问题——所有前端路由最终都应该回到index.html由 JS 再根据路径渲染对应组件。解决方案就是上面提到的try_files $uri $uri/ /index.html;。这个问题相当经典凡是 Vue 3 项目用 history 模式部署时几乎必踩。改完配置记得nginx -t检查语法再nginx -s reload生效。6.2 MySQL 时间差 8 小时时区导致的隐蔽问题问题现象前端页面展示的评论时间、登录时间都比实际时间少了 8 小时。排查过程最初怀疑是前端解析时间格式的问题翻完 axios 和日期处理工具后发现没问题。接着看 MySQL 的时区设置执行SHOW VARIABLES LIKE %time_zone%;发现是SYSTEM而服务器的系统时区是 UTC北京时间和 UTC 正好相差 8 小时。根因就是 JDBC 连接串里没有指定serverTimezone。解决方案JDBC URL 显式加上serverTimezoneAsia/Shanghai重启后端服务后时间正常。这个坑在本地开发时可能不出现因为本机系统时区恰好是东八区但云服务器很多默认是 UTC部署上去就立刻暴露。6.3 PageHelper 分页 count 查询特别慢问题现象番剧数据量到几千条后分页接口首页响应时间直线上升排查发现慢在 count 查询。排查过程打印 MyBatis 日志后发现PageHelper 自动生成的 count SQL 把多表关联全部子查询包裹了一层统计总数时走了全表扫描没有充分利用索引。看执行计划anime_info表的category_id索引在 count 场景根本没被用到。解决方案给 Mapper 方法自定义一个 count 查询只统计anime_info主表数量不关联分类表。分页总数精确值并不需要关联表数据count 结果差几条也没关系但速度提升明显。这之后我养成了习惯凡是有多表关联的分页查询都会顺手检查一下自动生成的 count SQL 是否走了正确索引。6.4 Vue3 响应式失效解构出来的数据改不动问题现象详情页里把接口返回的animeDetail对象通过解构赋给const { title, cover } animeDetail页面上修改title的值后视图不更新。排查过程这个问题的根因是 Vue3 的响应式代理机制。animeDetail是ref创建的对象解构出来的title是普通字符串变量已经脱离了 Proxy 的依赖追踪范围修改它当然不会触发重新渲染。这和 Vue2 的data机制有本质区别。解决方案模板里直接使用animeDetail.title或者在computed里基于animeDetail派生新值。解构ref对象时要记住Vue3 里只有reactive对象在深层访问时才保持响应性普通解构出的基础类型没有响应性。最后的个人体会整套系统从数据库设计到前端页面跑通再到部署上线我最大的感受是这类“内容展示 用户互动”的项目复杂度不在某一个单独的技术点而在所有模块如何顺畅地协作。数据库表之间怎么关联、接口返回什么结构、前端拿到数据后怎么渲染这三层必须对齐。建议准备做个类似项目的朋友先花半天认真画清楚表结构再动手写代码。表设计错了后面返工的代价是最高的。如果你是从网上下载的这套源码第一次跑通之后不要急着改功能先按照上面第 5 节的部署清单完整走一遍流程搞明白前端怎么构建、后端怎么启动、Nginx 怎么代理。这个过程走通了后面所有功能改动你都能自己掌控。只要能稳定跑起来一次你对整个技术栈的理解会往上跨一个台阶。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门