ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue+MyBatis+MySQL前后端分离动漫网站系统部署实战

SpringBoot+Vue+MyBatis+MySQL前后端分离动漫网站系统部署实战

上个月在群里又看到有人问:想搭一个国产动漫资讯站,自己练手用,前后端要分离,技术栈别太老也别太花哨,有没有能从零跑到线上的完整方案?我当时直接把手上一套整理过的项目丢了过去,就是标题里这套 SpringBoot + Vue + MyBatis + MySQL 的国产动漫网站系统。后端只出 JSON 接口,前端负责页面渲染,源码、数据库脚本、部署文档都齐全,照着步骤执行就能把整个网站跑起来。

如果你正在学 Java 全栈,想找一个能写进简历的完整项目;或者你正在准备毕业设计,需要一个前后端分离的实战案例;再或者你只是想把自己收藏的动漫资料整理成一个带后台管理功能的站点,这套系统的链路基本覆盖了所有环节。我下面会把功能拆解、技术选型逻辑、核心接口实现、前端页面设计、完整部署步骤,以及我实际开发中踩过的坑都写清楚。

1. 先看项目长什么样:页面、角色和功能拆分

1.1 用户能看到的页面:从首页到详情页

这个项目不是那种只有一个 CRUD 表格的后台管理系统,而是有完整前台展示功能的动漫内容站。前台部分我按正常用户浏览习惯拆成了几个页面:

首页是整个项目的门面,顶部是导航栏,中间是 banner 轮播区,下面是按照点击量排名的热门动漫卡片墙,底部带分页。首页数据来自后端的列表接口,前端通过 axios 请求拿到数据后用 v-for 渲染卡片,点卡片就跳详情页。

列表页承担分类筛选和搜索能力。用户可以从顶部导航点“全部动漫”进入,也可以点某个分类标签进入。页面上有筛选条件栏,支持按动漫类型、年份、地区组合筛选,关键词搜索走的是单独接口。这个页面我用的是动态路由,同一个页面组件通过 URL 上的 query 参数区分当前是分类列表还是搜索列表。

详情页展示单个动漫的完整信息:封面大图、标题、类型标签、地区、年份、状态、剧情简介、收藏按钮。这里需要注意,详情页的路由是/detail/:id,进入页面后要根据 id 调后端接口拿详情数据。

登录注册页面走的是表单校验,前端校验手机号格式、密码长度,通过后请求后端登录接口,拿到 JWT 令牌存到本地存储,后续请求都在请求头里带上这个令牌。

个人中心目前只做了“我的收藏”和“退出登录”两个核心功能,收藏列表是用户登录后通过 userId 查询出来的。考虑到收藏本身是用户数据里最有代表性的功能,所以我把它的前后端链路完整实现了,后续加评论、历史记录也都是同样的套路。

1.2 管理员后台:不只是增删改查

后台管理部分我单独做了一套页面,入口在登录页的“管理员登录”按钮,默认给了一个管理员账号。后台主要由四个功能块组成:

仪表盘页面显示基础统计数字:动漫总数、分类总数、注册用户数、收藏总数。这个功能不复杂,本质是后端提供几个 count 统计接口,前端用数字卡片展示,但对管理员来说一眼能看出站点规模,非常实用。

动漫管理页面是后台最核心的模块。管理员可以在表格中看到所有动漫条目,支持标题搜索,可以新增动漫、编辑已有条目、删除条目、上传封面图。新增和编辑共用同一个弹窗表单,表单里包含标题、分类、地区、年份、简介、封面地址这些字段。

分类管理就是非常直白的增删改查,三个字段:分类名称、排序值、备注。我提醒一句,分类删除时要判断该分类下是否还有动漫数据,否则会出现前台列表“孤儿数据”,这点很多人刚开始做会漏掉。

用户管理页面展示注册用户列表,支持搜索和禁用操作。禁用用户后,该用户的 JWT 令牌在鉴权拦截器中会被判定为无效,这比只删 token 要优雅得多。

1.3 三种角色怎么划分权限

整个系统按角色分成三类:游客、注册用户、管理员。我做权限的思路很直接,后端拦截器按 URL 前缀区分:

游客不需要登录,能访问的接口是首页列表、分类列表、详情、搜索这些公开接口;注册用户可以访问收藏相关接口;管理员可以访问/api/admin/**下的所有接口。

前端也做了对应的路由守卫:个人中心需要登录后才能进入,后台管理页面需要本地存储里有管理员身份标识才能进入。

角色可访问页面可执行操作身份验证
游客首页、列表页、详情页浏览、搜索、查看详情无
注册用户以上全部 + 个人中心收藏动漫、取消收藏、退出登录JWT 令牌
管理员以上全部 + 后台管理页动漫增删改、分类管理、用户管理、统计查看JWT 令牌 + 管理员角色校验

这套权限模型虽然简单,但覆盖了绝大多数内容站点的基本需求。如果后续要加评论、点赞,只需要在拦截器里配置对应的接口路径和角色即可。

2. 技术选型不是拍脑袋:四个核心组件各自解决了什么问题

2.1 SpringBoot:把后端开发从“配置地狱”里解放出来

早期做 Java Web,最麻烦的不是写业务代码,而是配置各种 XML。一个 SSM 项目要配 web.xml、Spring 配置、SpringMVC 配置、MyBatis 配置,还要处理各类 jar 包依赖冲突。SpringBoot 的核心价值是约定优于配置,你只需要引入spring-boot-starter-web,再配合自动配置,就能直接写 Controller。

这个项目中我只是按标准的分层结构组织代码:Controller 接收请求和参数,Service 处理业务逻辑,Mapper 操作数据库。SpringBoot 的依赖注入很好地解耦了三层,比如 Controller 里不用手动 new Service,直接@Autowired注进来就行。

实际开发中我还有一个习惯:所有接口返回统一的数据结构Result<T>,包含 code、msg、data 三个字段。这样前端在做 axios 响应拦截时,只需要判断 code 是否为 200,不用针对每个接口单独解析异常结构,非常省事。

2.2 Vue:为什么放弃 JSP,改用前后端分离

如果说 JSP 时代的典型问题是 HTML、CSS、Java 代码全混在一起,那前后端分离的核心思路就是“后端不懂页面,前端不懂数据库”。后端把数据通过 JSON 接口吐出来,前端只负责把数据渲染成页面。这样两边可以独立开发、独立部署,接口约定好就能并行推进。

Vue 在这个项目里的优势主要体现在三方面:第一,组件化非常方便,动漫卡片、分页器、搜索表单、后台表格都能抽成独立组件复用;第二,响应式数据绑定让“请求数据—填充页面”这个操作变成了直接给 data 赋值,视图自动更新;第三,生态成熟,Element Plus 提供了后台管理需要的表格、表单、弹窗、上传组件,UI 不用从头写。

我选择 Vue3 + Vite 而不是 Vue2 + Webpack,是因为 Vite 在开发模式下冷启动和热更新速度都明显更快,对本地调试体验提升很大。

2.3 MyBatis + MySQL:查询要掌控在自己手里

有人问为什么不用 JPA?我的回答很实际:列表页的搜索、筛选、分类统计这类场景,需要对 SQL 有精确控制能力。JPA 擅长单表 CRUD 和简单的关联查询,但一旦出现多条件动态拼接、多表关联、复杂统计这类查询,调优 SQL 就会很别扭。

MyBatis 是半自动 ORM,SQL 由开发者手写,参数映射和结果映射交给框架处理。这样既保留了 SQL 的灵活性,又解决了手动操作 JDBC 那套繁琐的ResultSet映射问题。在这个项目里,动漫列表的分页查询、收藏列表的关联查询、后台的统计查询,我都写了对应的 Mapper XML,逻辑一目了然。

MySQL 那边没什么好说的,稳定、部署轻量、资料多,对一个学习项目来说完全够用。数据库设计时我特意统一了字符集为utf8mb4,否则存中文标题和简介时很容易出现乱码。

2.4 推荐技术栈版本组合

如果照着这个项目做,我建议版本按这个组合来,都验证过能正常跑:

组件推荐版本说明
JDK1.8 或 17两者都可以,SpringBoot 2.7 兼容
SpringBoot2.7.x稳定,学习资料多
MyBatis3.5.x + mybatis-spring-boot-starter 2.x半自动 ORM 标准配置
MySQL8.0.x字符集记得用 utf8mb4
Vue3.x组合式 API 写起来更顺手
Vite4.x开发服务器和构建工具
Element Plus2.x后台页面 UI 组件库
Node.js16.x 或 18.x运行前端脚手架和构建命令

3. 后端核心实现:一个“动漫列表页”身后的完整链路

3.1 核心表结构设计

数据库我命名为anime_db,核心表包括系统用户表、管理员表、动漫分类表、动漫信息表、收藏表。下面是精简后的建表语句,我在每个表里都加了必要的索引和注释,方便直接导入:

CREATE DATABASE anime_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID', username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', avatar VARCHAR(255) DEFAULT NULL COMMENT '头像地址', status TINYINT DEFAULT 1 COMMENT '1启用 0禁用', create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) COMMENT '系统用户表'; CREATE TABLE sys_admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL ) COMMENT '管理员表'; CREATE TABLE anime_category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL COMMENT '分类名称', sort INT DEFAULT 0 COMMENT '排序值越小越靠前', remark VARCHAR(255) DEFAULT NULL ) COMMENT '动漫分类表'; CREATE TABLE anime_info ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '动漫标题', cover_url VARCHAR(255) COMMENT '封面图片地址', intro TEXT COMMENT '剧情简介', category_id INT COMMENT '所属分类ID', area VARCHAR(30) COMMENT '地区', publish_year INT COMMENT '上映年份', status TINYINT DEFAULT 1 COMMENT '1连载中 2已完结', click_count INT DEFAULT 0 COMMENT '点击量', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, KEY idx_category (category_id), KEY idx_publish_year (publish_year) ) COMMENT '动漫信息表'; CREATE TABLE anime_favorite ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, anime_id INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_anime (user_id, anime_id) ) COMMENT '用户收藏表';

这里有两个设计细节值得说明。第一,密码字段存的是 BCrypt 加密后的密文,绝不能存明文;第二,收藏表加了(user_id, anime_id)唯一索引,从数据库层面防止用户重复收藏,比前端判重可靠得多。

3.2 RESTful 接口规划

前后端分离项目的关键约定是接口文档。我按 RESTful 风格规划了主要接口,前端调用时基本不用想太多,路径和请求方式一目了然:

方法路径功能是否需要登录
GET/api/anime/list分页查询动漫列表,支持分类、年份、地区筛选否
GET/api/anime/{id}根据ID查询动漫详情否
GET/api/anime/search按关键词搜索动漫否
GET/api/category/list获取全部分类否
POST/api/user/login用户登录否
POST/api/user/register用户注册否
GET/api/user/favorites获取当前用户收藏列表是
POST/api/user/favorite/{animeId}添加收藏是
DELETE/api/user/favorite/{animeId}取消收藏是
GET/api/admin/statistics后台统计管理员
POST/api/admin/anime新增动漫管理员
PUT/api/admin/anime编辑动漫管理员
DELETE/api/admin/anime/{id}删除动漫管理员

3.3 MyBatis 动态 SQL 与分页查询

动漫列表是最典型的动态查询场景:分类不为空时拼分类条件,关键词不为空时拼模糊搜索条件,年份区间不为空时拼年份条件。这种需求用 MyBatis 的<if>标签非常清晰。

我直接用 PageHelper 做物理分页,一行代码就能在查询前拦截并自动生成 LIMIT 语句:

// Service 层代码 public PageResult<AnimeVO> pageAnime(Integer pageNum, Integer pageSize, Integer categoryId, String keyword, Integer year) { PageHelper.startPage(pageNum, pageSize); List<AnimeVO> list = animeMapper.selectAnimePage(categoryId, keyword, year); PageInfo<AnimeVO> pageInfo = new PageInfo<>(list); return PageResult.from(pageInfo); }

对应的 Mapper XML 长这样,注意我用了<where>标签,它会智能去掉第一个多余的 AND:

<select id="selectAnimePage" resultType="com.example.anime.vo.AnimeVO"> SELECT a.id, a.title, a.cover_url, a.intro, a.publish_year, a.status, a.click_count, c.name AS category_name FROM anime_info a LEFT JOIN anime_category c ON a.category_id = c.id <where> <if test="categoryId != null"> AND a.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND a.title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="year != null"> AND a.publish_year = #{year} </if> </where> ORDER BY a.click_count DESC </select>

这里有个容易踩的坑:如果只查动漫主表,可以用 MyBatis 的自动驼峰映射;但一旦 LEFT JOIN 分类表查询分类名称,最好用 VO 对象接收,把category_name这种带下划线的列直接映射到categoryName属性上,避免前端拿到 null。

3.4 登录鉴权:拦截器 + JWT

整个鉴权链路是这样的:用户登录成功,后端生成一个 JWT 令牌返回给前端;前端把令牌存到 localStorage;每次请求在 axios 拦截器里加上Authorization: Bearer <token>;后端定义一个登录拦截器,拦截需要认证的路径,解析令牌成功后把用户信息放入上下文。

核心代码大致如下:

// 登录 Service @Override public Result login(String username, String password) { SysUser user = userMapper.findByUsername(username); if (user == null || !BCrypt.checkpw(password, user.getPassword())) { return Result.error("用户名或密码错误"); } if (user.getStatus() == 0) { return Result.error("账号已被禁用"); } String token = JwtUtil.createToken(user.getId(), user.getUsername(), "user"); return Result.success(Collections.singletonMap("token", token)); }

注册的时候,密码一定用BCrypt.hashpw(password, BCrypt.gensalt())加密后再入库。千万不要用 MD5,现在 MD5 加盐都很容易被彩虹表破解,BCrypt 是行业标准做法。

拦截器里我做了一个比较实用的设计:从 token 中解析出用户的角色字段,如果是/api/admin/**开头的路径且角色不是 admin,直接返回 403。这样后台接口和普通用户接口共用一个拦截器,只是通过路径前缀做了一次角色判断。

4. 前端实现思路:Vue Router、Axios 与组件拆分

4.1 前端工程结构

前端我用 Vite 创建项目,整个src目录结构如下:

src/ api/ anime.js # 动漫相关接口 user.js # 用户登录注册收藏 admin.js # 后台管理接口 assets/ # 静态资源 components/ AnimeCard.vue # 动漫卡片组件 Pagination.vue# 分页组件 router/ index.js # 路由配置 store/ user.js # 用户状态 views/ Home.vue AnimeList.vue AnimeDetail.vue Login.vue UserCenter.vue admin/ Dashboard.vue AnimeManage.vue CategoryManage.vue UserManage.vue main.js App.vue

这种按功能模块分目录的方式可能和网上很多教程不一样,但实际维护下来很清晰。api目录单独放接口请求函数,页面组件里不直接写 axios 代码,只调用 api 模块暴露的函数。这样以后后端地址变了,只需要改一个配置文件。

4.2 路由与导航守卫

路由配置使用了 Vue Router 4。一个设计上的关键点是详情页和后台页面用动态路由和懒加载,避免首屏一次性加载所有页面代码:

const routes = [ { path: '/', name: 'Home', component: () => import('@/views/Home.vue') }, { path: '/list', name: 'AnimeList', component: () => import('@/views/AnimeList.vue') }, { path: '/detail/:id', name: 'AnimeDetail', component: () => import('@/views/AnimeDetail.vue') }, { path: '/login', name: 'Login', component: () => import('@/views/Login.vue') }, { path: '/user', name: 'UserCenter', component: () => import('@/views/UserCenter.vue'), meta: { requiresAuth: true } }, { path: '/admin', component: () => import('@/views/admin/Layout.vue'), meta: { requiresAdmin: true }, children: [ { path: '', redirect: '/admin/dashboard' }, { path: 'dashboard', component: () => import('@/views/admin/Dashboard.vue') }, { path: 'anime', component: () => import('@/views/admin/AnimeManage.vue') }, { path: 'category', component: () => import('@/views/admin/CategoryManage.vue') }, { path: 'user', component: () => import('@/views/admin/UserManage.vue') } ] } ]

导航守卫做两件事:一是检查meta.requiresAuth,没登录就跳登录页并带上回跳地址;二是检查meta.requiresAdmin,本地存储里没有管理员标识就提示无权限并跳回首页。

4.3 Axios 封装与状态管理

整个前端共用一个 axios 实例,我在api/request.js里做了统一封装:

import axios from 'axios' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || '/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 => { const res = response.data if (res.code !== 200) { if (res.code === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(new Error(res.msg)) } return res.data }, error => Promise.reject(error) ) export default request

用户状态我用一个轻量的 store 来管理,存了用户 id、用户名、是否管理员。每次刷新页面时会先从 localStorage 里恢复基础信息;如果登录过期,请求接口时会收到 401,响应拦截器自动清掉本地身份并跳转登录页。

4.4 核心组件怎么拆分

动漫卡片是比较典型的复用组件,在首页、列表页、后台表格弹窗里都会用到。我把AnimeCard.vue设计成一个纯展示组件,只接收一个anime对象作为 prop,输出封面、标题、点击量和分类名称:

<template> <div class="anime-card" @click="goDetail"> <img :src="anime.coverUrl" alt="封面" /> <h3>{{ anime.title }}</h3> <div class="meta"> <span>{{ anime.categoryName }}</span> <span>{{ anime.clickCount }} 次浏览</span> </div> </div> </template> <script setup> import { useRouter } from 'vue-router' const props = defineProps({ anime: { type: Object, required: true } }) const router = useRouter() function goDetail() { router.push(`/detail/${props.anime.id}`) } </script>

这样设计的价值在于:前端组件关注点分离,展示逻辑写在组件内部,数据获取留在页面组件里。排查问题时,如果数据不对就查页面组件和接口,如果展示不对就查卡片组件,不用在几百行代码里翻来翻去。

5. 从零到一:本地完整部署运行,看这篇就够

5.1 环境准备清单

部署之前,先确认本机安装了以下基础环境。没有安装的可以先去官网下载,这里不展开安装细节:

软件最低版本要求检查命令
JDK1.8 或 17java -version
Maven3.6+mvn -v
Node.js16+node -v
MySQL8.0mysql --version
Git可选git --version

我建议把 MySQL 字符集设置成 utf8mb4,这样既能兼容中文,又能支持将来存 emoji 表情,避免出现存储时截断报错的问题。

5.2 初始化数据库

拿到项目源码后,找到sql/anime_db.sql这个脚本文件。用命令行导入或者用可视化工具执行都行:

mysql -u root -p anime_db < anime_db.sql

如果你用的是 Navicat,直接连接数据库后新建库,再右键运行 SQL 文件即可。脚本里除了五张核心表,还预置了管理员账号admin / admin123、一个测试用户账号,以及几十条虚构的动漫演示数据。

导入完成后,可以用下面这条 SQL 做一次完整验证,确认分类表和数据表关联正常:

SELECT a.title, c.name, a.publish_year FROM anime_info a LEFT JOIN anime_category c ON a.category_id = c.id LIMIT 10;

5.3 后端配置修改与启动

后端项目根目录下有src/main/resources/application.yml,核心配置就是数据源和 JWT。打开文件,把数据库账号密码改成你本机的值:

server: port: 8080 spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/anime_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true jwt: secret: your-custom-secret-key expire: 604800

然后启动后端,两种方式:

# 方式一:开发调试模式 mvn spring-boot:run # 方式二:打包后运行 mvn clean package -DskipTests java -jar target/anime-server.jar

启动成功后,后端会监听 8080 端口。可以用浏览器直接访问http://localhost:8080/api/category/list,如果能看到 JSON 数据,说明后端已经正常工作了。这一步是排查问题的快速抓手,我强烈建议在碰前端之前先验证后端接口。

5.4 前端依赖安装与开发模式启动

前端目录是web/。先进入目录,安装依赖:

cd web npm install npm run dev

默认启动端口是 5173。此时浏览器访问http://localhost:5173,页面会通过 Vite 的代理把/api开头的请求转发到后端 8080 端口。代理配置在vite.config.js里:

export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

到这里,前后端已经联调通了。你可以试试注册一个账号、给某条动漫加收藏、退出登录,再从管理员入口进入后台,体验完整权限链路。

5.5 打包发布部署

如果要上线,前后端要分开部署。前端跑构建命令:

cd web npm run build

构建完成后,dist目录里的静态文件放到 Nginx 的 html 目录,然后配置反向代理,把/api请求转发给 Java 服务:

server { listen 80; server_name your-domain.com; 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; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }

注意如果后端单独跑在云服务器上,记得在安全组放行 8080 端口,或者只通过 Nginx 反向代理暴露 80,后端 8080 不直接对外开放。这是基本的端口安全习惯。

6. 我实际开发中踩过的坑,以及后续优化方向

6.1 跨域问题:前后端联调第一关

前后端分离开发时,前端跑在 5173,后端跑在 8080,端口不同必然产生跨域。我最初直接用后端解决,写了一个全局 CorsFilter;后来发现这样得在生产环境也处理,而且要是以后前端域名变了,配置都得跟着改。

更好的方案是开发环境用 Vite 代理,生产环境用 Nginx 反向代理。这样从浏览器看,所有请求都是同源的,跨域问题在后端根本不出现。如果你非要在后端配置跨域,可以这样:

@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowCredentials(true); } }

6.2 MyBatis 字段映射:下划线转驼峰的坑

这个坑非常隐蔽。数据库字段是publish_year,Java 实体属性是publishYear,没有配置驼峰映射时,MyBatis 会把查询结果里的publish_year字段赋值给publishYear失败,页面上的年份显示就是 null。

解决办法是全局开启驼峰映射,在application.yml里加上:

mybatis: configuration: map-underscore-to-camel-case: true

万一加了之后某个字段还是 null,就要检查 Mapper XML 里是不是手动写了 resultMap,手动 resultMap 会覆盖全局配置,这时需要自己在 resultMap 里显式做column="publish_year" property="publishYear"映射。

6.3 图片资源访问 404

后台封面上传后,图片会保存到本地磁盘的upload目录。默认情况下 SpringBoot 不会把项目外的路径当作静态资源处理,直接访问/upload/xxx.jpg会 404。

我在项目里写了一个资源映射配置,把/upload/**映射到本地绝对路径:

@Configuration public class FileConfig implements WebMvcConfigurer { @Value("${file.upload-dir}") private String uploadDir; @Override public void addResourceHandlers(ResourceHandlerRegistry registry) { registry.addResourceHandler("/upload/**") .addResourceLocations("file:" + uploadDir + "/"); } }

生产环境上线前,建议把上传目录改成独立磁盘路径,别放在项目 jar 包同一级目录下,不然升级部署时容易把用户上传的图片清掉。

6.4 慢查询与索引优化

功能开发完还没有真正跑起来之前,我先往动漫表里插了 2 万条测试数据,实测发现列表页首次响应稳定在 1.5 秒左右,明显偏慢。用 EXPLAIN 看执行计划后发现,WHERE category_id = xxx ORDER BY click_count DESC这条查询走了全表扫描。

解决办法很简单,给高频查询字段加索引:

ALTER TABLE anime_info ADD INDEX idx_category_click (category_id, click_count);

加完索引后同一查询耗时降到 0.05 秒左右。这个优化思路放在任何项目里都通用:先排查慢 SQL,再针对 WHERE 和 ORDER BY 的字段建立联合索引。需要提醒的是,LIKE '%关键词%'这种模糊查询即使有索引也用不上,所以搜索量不大时可以先接受,后面量大了再考虑全文索引方案。

6.5 后续可以继续扩展的方向

这套系统目前已经完成了内容展示、分类检索、用户收藏、后台管理这几条完整链路,后续如果还想继续加内容,我建议按下面顺序扩展:

  • 评论模块:动漫详情页加评论区,核心是新增评论表和用户关联查询。
  • 每日推荐:在首页加一个“小编推荐”区块,后台可以设置推荐套餐。
  • 浏览历史:记录用户最近浏览的动漫列表,就是收藏表加一个历史记录表。
  • 数据缓存:热点列表接口加 Redis 缓存,降低 MySQL 压力。

我个人的习惯是每完成一个独立功能模块,就顺手用 Postman 把接口全部过一遍,再回到前端验证页面。这样排查问题很快,基本不会出现攒了一堆 Bug 最后一起炸的情况。这套动漫网站系统做到当前程度,已经能把 SpringBoot、MyBatis、Vue 三者的核心配合逻辑完整跑明白,照着这份部署文档实操一遍,你对前后端分离项目的理解会上一个台阶。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进