简介:一套面向Java毕业设计与Web开发学习者的“腾达”游戏分享网站完整源码包。项目采用SpringBoot+Vue+MySQL技术栈,涵盖用户注册登录、游戏资源上传下载等核心功能,前后端分离、模块化设计,适合课程设计、毕业设计或作为现代Java Web开发的练手模板。压缩包共651个文件,大小约24.22MB,以140个Java后端类、105个Vue前端组件、63个JavaScript脚本为主,并配有SQL数据库脚本、XML配置文件、Maven工程管理文件、启动运行bat脚本及说明文档,同时收录大量SVG/PNG/JPG图片素材,目录划分清晰,便于按功能模块检索。当前已有52人浏览学习,文档中提供数据库表结构与字段说明,帮助使用者快速理解数据设计;项目经测试可完美运行,既能帮助初学者掌握SpringBoot+Vue前后端交互与部署流程,也可供有经验开发者直接作为模板扩展游戏分享类业务,具备较高的参考与二次开发价值。
1. 游戏分享网站选题:为什么 Spring Boot + Vue + MySQL 能撑起整套毕设
游戏分享类网站在 Java 毕业设计里出现频率极高,核心业务并不复杂:用户注册登录、浏览游戏分享列表、发布新的游戏内容、给游戏留言评论。这套业务恰好把 springboot 后端接口、vue 前端页面、mysql 数据存储串成一条完整链路,每一个环节都能展开讲出深度。对比纯管理系统,它多了内容展示与用户互动;对比商城类项目,它又省去了支付、库存这些高复杂度模块,工作量和答辩深度都容易把控。这篇内容会围绕该项目的常见实现方式,把库表设计、核心接口、前端对接讲透,再把最容易翻车的几个点按现象、原因、解决的方式拆开。
2. 先设计 MySQL 表再写代码:游戏分享网站的库表与初始化脚本
很多新手拿到源码第一件事是解压、配数据库、启动,结果发现登录报错、列表空白,最后才回头研究表结构。正经顺序应该是先把数据模型定死,因为后端接口写什么、前端页面展示什么,全由表决定。
2.1 用户、游戏、分类、评论:四张核心表的字段定稿
游戏分享网站最少要四张表才能跑通主流程,先看用户表。用户表的核心字段是 id、username、password、nickname、avatar、role、create_time。password 字段直接存明文是毕设里最常见的翻车点,因为后端一旦写登录接口,就绕不开密码比对,明文存储意味着数据库泄露就等于账号泄露。常见做法是后端用 BCrypt 加密,用户注册时把加密结果写进库,登录时用校验方法比对。
CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID', `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', `password` VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', `nickname` VARCHAR(50) DEFAULT '' COMMENT '展示昵称', `avatar` VARCHAR(255) DEFAULT '' COMMENT '头像URL', `role` TINYINT DEFAULT 0 COMMENT '角色:0普通用户,1管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '注册时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';password 字段长度给 100 而不是 32,因为 BCrypt 生成的哈希串是 60 位左右,设短了插入直接报错。role 字段用 TINYINT 而不是字符串,是为了后续做管理员权限拦截时比较方便。create_time 直接用数据库当前时间,后端代码里不需要再手动 set。
游戏表是这套系统的核心,字段比用户表多不少。常见设计是 id、title、cover、description、category_id、download_url、user_id、status、view_count、create_time。title 是游戏名称,cover 是封面图 URL,description 是简介,category_id 关联分类表,download_url 放分享链接,user_id 标记是谁发布的,status 控制是否在前台展示。
CREATE TABLE `game` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '游戏ID', `title` VARCHAR(100) NOT NULL COMMENT '游戏名称', `cover` VARCHAR(255) DEFAULT '' COMMENT '封面图URL', `description` TEXT COMMENT '游戏简介', `category_id` INT NOT NULL COMMENT '所属分类ID', `download_url` VARCHAR(255) DEFAULT '' COMMENT '下载或分享链接', `user_id` INT NOT NULL COMMENT '发布者ID', `status` TINYINT DEFAULT 0 COMMENT '状态:0待审核,1已上架,2已下架', `view_count` INT DEFAULT 0 COMMENT '浏览量', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游戏分享表';status 字段容易被忽略。许多毕设为了省事不加它,所有记录直接展示,但答辩时评委常会问"如果用户上传违规内容怎么处理",有审核状态就能答上。view_count 用冗余字段而不是单独建浏览记录表,是因为这个项目的核心是展示,不是统计,每次详情页查询时加一就可以。
分类表很简单,id、name、sort 三个字段就够。评论表要考虑的是关联关系,评论挂在游戏下而不是用户下,因为页面入口是游戏详情页。核心字段是 id、game_id、user_id、content、create_time。如果需要楼层或回复功能,再加 parent_id,但毕设做到评论展示基本够用,不加也不影响主流程。
2.2 收藏表和轮播图表:给答辩加分的两张辅助表
四张核心表能跑通功能,但要想在答辩和文档里多写几页,推荐补上收藏表和轮播图表。收藏表的定位是"用户看到想玩的游戏先收藏,回头再找",字段就四个:id、user_id、game_id、create_time。它的价值在于能引出"多对多关系"这一考点,用户可以收藏多个游戏,一个游戏可以被多个用户收藏。前后端能做收藏按钮的交互,也能在个人中心做收藏列表展示。
CREATE TABLE `favorite` ( `id` INT PRIMARY KEY AUTO_INCREMENT COMMENT '收藏ID', `user_id` INT NOT NULL COMMENT '用户ID', `game_id` INT NOT NULL COMMENT '游戏ID', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '收藏时间', UNIQUE KEY `uk_user_game` (`user_id`, `game_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='收藏表';这里唯一索引 uk_user_game 特别重要。如果没有它,用户快速点两次收藏按钮就会插入两条重复记录,前端按钮状态也跟着乱。加了唯一索引后,后端做插入时捕获 DuplicateKeyException,就能判断出用户是否已收藏,逻辑反而简单。
轮播图表是典型的"为了首页好看"而存在的表。首页顶部要展示几张 banner 图,一般固定位置给图片。字段是 id、image_url、link_url、sort。这张表的意义不在业务,而在让前端首页不写死图片路径,管理端能动态替换。答辩时讲"首页展示与后台可配置",比静态页面有说服力得多。
2.3 MySQL 初始化脚本组织方式:字符集、排序规则、外键
表结构定完之后,初始化脚本要解决两个实际问题:建库的字符集和表的排序规则。中文乱码的根源百分之八十在建库语句里,默认 latin1 存中文就会出问题。建库时直接指定 utf8mb4 是最稳的做法,因为 utf8mb4 是 UTF-8 的超集,能存 emoji 这类四字节字符,评论里用户偶尔带个表情不会炸。
CREATE DATABASE IF NOT EXISTS game_share DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE game_share;字符集定了,表里的中文、甚至特殊符号都能正常存。外键这块我建议尽量少加物理外键。游戏表的 category_id、user_id 在逻辑上是一对多关系,但物理外键会让删除数据时束手束脚,比如删一个分类时游戏表被外键约束挡住。而且答辩时评委如果问"为什么不用外键",标准回答是"外键约束影响批量插入性能,业务层通过代码保证数据一致性"。这个回答比"我不会加外键"要好很多。
常见的初始化脚本组织方式是一份 sql 文件包含建库、建表、插入初始数据三个部分。初始数据里一定要有管理员账号、几个游戏分类和几条游戏记录,否则第一次启动前端页面是空的,会误以为系统没跑起来。
3. Spring Boot 接口落地:JWT 登录、图片上传、分页搜索三段代码
表设计完,后端接口就可以动工了。接口层面,游戏分享网站的核心集中在三块:登录鉴权、游戏发布、列表分页。这三块代码写完整,剩下的评论、收藏接口都是同类套路。
3.1 JWT 登录与拦截器:让每个接口都知道"当前用户是谁"
登录接口的常见实现是 JWT 无状态认证。用户提交用户名密码,后端校验通过后签发一个 token,前端把 token 存在浏览器里,之后每次请求在 Authorization 头里带上,后端拦截器解析出用户信息。
@Component public class JwtUtil { private final String SECRET = "game-share-secret-key"; public String createToken(Integer userId, String username) { return JWT.create() .withClaim("userId", userId) .withClaim("username", username) .withExpiresAt(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) .sign(Algorithm.HMAC256(SECRET)); } public Integer parseUserId(String token) { DecodedJWT jwt = JWT.require(Algorithm.HMAC256(SECRET)).build().verify(token); return jwt.getClaim("userId").asInt(); } }这段代码里 SECRET 是签名密钥,生产环境应该放到 application.yml 的配置项里,而不是写死在代码中,但毕设项目里常量定义问题不大。过期时间设 2 小时,这个值要看使用习惯,如果用户频繁操作,2 小时略短;如果只是答辩演示,只需要演示期间不重新登录即可。userId 是后续发布游戏、发表评论时的身份依据,几乎每个业务接口都要用。
有了 token 工具类,还需要拦截器把它应用到接口上。拦截器的职责是解析请求头里的 token,把 userId 放进去。
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if ("OPTIONS".equals(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "用户未登录"); } Integer userId = jwtUtil.parseUserId(token.substring(7)); request.setAttribute("userId", userId); return true; } }这里值得注意的有两点。第一,CORS 预检请求 OPTIONS 必须放行,否则前端跨域请求会在拦截器这一层被拦死。第二,拦截到 userId 后通过 request.setAttribute 传递,Controller 里用 @RequestAttribute("userId") Integer userId 取,用这种方式比重新解析 token 更干净,也不需要引入 ThreadLocal 存储用户信息。配置注册在拦截器里,需要放行的接口明确排掉,比如注册、登录、游戏列表、游戏详情和图片预览。
@Configuration public class WebMvcConfig implements WebMvcConfigurer { @Autowired private AuthInterceptor authInterceptor; @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(authInterceptor) .addPathPatterns("/api/**") .excludePathPatterns( "/api/user/login", "/api/user/register", "/api/game/list", "/api/game/detail/**", "/upload/**" ); } }登录接口本身逻辑简单,接收用户名密码,先查用户,再用 BCryptPasswordEncoder 校验密码。校验通过就返回 token,前端拿到后存起来。
3.2 游戏发布接口:图片上传和 multipart 参数的处理细节
游戏发布涉及两类数据:文本信息和封面图片。前端表单通常会先用异步上传把图片传上去拿到 URL,再随表单提交文本信息。也有人把图片和文本放在一个 multipart 请求里提交,但那样前后端代码都会复杂一些,常见做法是拆成两个接口。
@RestController @RequestMapping("/api/file") public class FileController { @Value("${file.upload-dir}") private String uploadDir; @PostMapping("/upload") public Result<String> upload(@RequestParam("file") MultipartFile file) throws IOException { if (file.isEmpty()) { throw new BusinessException(400, "上传文件不能为空"); } String originalFilename = file.getOriginalFilename(); String ext = ""; if (originalFilename != null && originalFilename.contains(".")) { ext = originalFilename.substring(originalFilename.lastIndexOf(".")); } List<String> allowedExts = Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".webp"); if (!allowedExts.contains(ext.toLowerCase())) { throw new BusinessException(400, "仅支持 jpg/png/gif/webp 图片"); } String filename = UUID.randomUUID().toString().replace("-", "") + ext; File dir = new File(uploadDir); if (!dir.exists()) { dir.mkdirs(); } file.transferTo(new File(dir, filename)); return Result.success("/upload/" + filename); } }后缀名校验是必须的,否则用户传一个 jsp 或 exe 上去,网站就变成了文件托管。UUID 重命名是为了避免两个用户传相同文件名的图片互相覆盖。前端拿到返回的路径后,发布游戏接口只接收 JSON 数据,cover 字段填上传接口返回的路径。
发布游戏的接口要注意 user_id 的来源,千万不能信任前端传过来的 userId,因为用户可以伪造请求体。正确写法是从拦截器设置的 request 属性中取。
@RestController @RequestMapping("/api/game") public class GameController { @PostMapping("/publish") public Result<?> publish(@RequestBody Game game, @RequestAttribute("userId") Integer userId) { game.setUserId(userId); game.setStatus(0); game.setViewCount(0); gameService.save(game); return Result.success(game.getId()); } }这里 status 固定为 0 是常见审核流程:用户发布的内容先进待审核状态,管理员在后台上架后在首页可见。如果答辩时不想做审核逻辑,可以让 status 默认 1 直接上架,但表结构里保留这个字段,代码里不做强制限制即可。
3.3 分页与搜索:MyBatis-Plus 的 LambdaQueryWrapper 写法
游戏列表页是 q 前端最核心的展示页面,一定会做分页和搜索。常见技术选型是 MyBatis-Plus,因为它的分页插件和条件构造器能少写大量 SQL。
@Override public IPage<Game> searchGames(int page, int pageSize, Integer categoryId, String keyword) { LambdaQueryWrapper<Game> wrapper = new LambdaQueryWrapper<>(); wrapper.eq(Game::getStatus, 1) .eq(categoryId != null, Game::getCategoryId, categoryId) .like(StringUtils.hasText(keyword), Game::getTitle, keyword) .orderByDesc(Game::getCreateTime); return gameMapper.selectPage(new Page<>(page, pageSize), wrapper); }这段代码的关键在于条件构造器里用了带布尔参数的 eq 和 like 方法。当 categoryId 为 null 时,.eq(false, ...) 会让 MyBatis-Plus 自动忽略这个条件,不需要写 if 判断。keyword 为空时同理。简单说,就是"前端不传的参数不会进 SQL",避免了一个常见问题:用户不选分类时查询条件为空导致结果不对。
分页插件需要在配置类里注册,否则 selectPage 只会查出全部数据而不做物理分页。
@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }接口层接收分页参数时,page 和 pageSize 要设置默认值,前端如果不传就不至于报空指针。返回值统一用 Result 对象包一层,结构为 code、message、data,这样前端 axios 封装时能统一处理错误码。
4. Vue 前端消费接口:路由、请求封装、列表和详情页怎么组织
后端接口写完,前端要能把页面跑起来。Vue 环境配置是很多人的第一道坎,Node.js 版本、npm 镜像源、vue 项目脚手架之间总有一个会出问题。这边就不展开环境安装的每一步了,重点讲项目结构和接口对接方式,因为在真实项目中,架子搭好以后的问题都出现在代码组织上。
4.1 Vue 项目结构和路由设计:前台大厅与后台管理两套 layout
游戏分享网站的前端一般拆两个区域:面向游客和普通用户的前台展示区,以及面向管理员的后台管理区。前台包含游戏列表、游戏详情、登录注册,后台包含用户管理、游戏审核、分类管理。
src/ ├── api/ │ ├── user.js │ └── game.js ├── router/ │ └── index.js ├── store/ │ └── user.js ├── views/ │ ├── front/ │ │ ├── GameList.vue │ │ ├── GameDetail.vue │ │ └── Login.vue │ └── admin/ │ ├── AdminGame.vue │ └── AdminUser.vue ├── utils/ │ └── request.js └── App.vue路由设计上,前台和后台要分开 layout,因为两侧的导航栏和整体布局完全不同。常见做法是父路由带一个组件壳子,子路由渲染到嵌套的 router-view 里。
const routes = [ { path: '/', component: () => import('@/layout/FrontLayout.vue'), children: [ { path: '', component: () => import('@/views/front/GameList.vue') }, { path: 'game/:id', component: () => import('@/views/front/GameDetail.vue') } ] }, { path: '/admin', component: () => import('@/layout/AdminLayout.vue'), children: [ { path: '', redirect: '/admin/game' }, { path: 'game', component: () => import('@/views/admin/AdminGame.vue') }, { path: 'user', component: () => import('@/views/admin/AdminUser.vue') } ] }, { path: '/login', component: () => import('@/views/front/Login.vue') } ]路由懒加载使用箭头函数 import 的写法,打包后每个页面单独生成 chunk,首屏加载速度快很多。这个细节在 Nginx 部署时很关键,因为如果全部代码打进一个 bundle,项目大了以后首屏会明显卡顿。另外注意 game/:id 这种带参数路由,详情页里通过 this.$route.params.id 取参数,发起详情查询。
4.2 Axios 封装:请求拦截器注入 token,响应拦截器处理 401
前端和后端对接时,最影响开发效率的就是请求代码不统一。有人直接在组件里 call axios,每个页面重复写 token 注入和错误处理。常规做法是封装一个 request 实例,把公共逻辑全部收拢。
// src/utils/request.js import axios from 'axios' import { ElMessage } from 'element-plus' import router from '@/router' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:每次请求自动带上 token 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) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, error => { if (error.response && error.response.status === 401) { localStorage.removeItem('token') router.push('/login') } ElMessage.error('网络错误,请稍后重试') return Promise.reject(error) } ) export default requestbaseURL 设置成 '/api' 而不是完整的 http://localhost:8080/api,是为了部署时让 Nginx 把 /api 开头的请求转发到后端,前端代码不用区分环境。这个设计在本地开发时用 vite 的 proxy 转发,在生产环境用 nginx 转发,前端代码完全不用改。token 存在 localStorage 里,刷新页面后依然有效,这是游戏分享网站登录状态保持的关键。
响应拦截器里把 response.data 直接返回,业务代码里拿到的就是后端 Result 里的 data 部分。比如 getGameList 返回的不是 axios 的 response 对象,而直接是数组或分页对象。这个做法能减少一层解包操作,但前提是后端 Result 结构要固定,code、message、data 三个字段不能变。
4.3 游戏列表页与详情页:组件划分和数据流转怎么设计
列表页是整个系统最典型的 CRUD 展示页面。它需要完成:调接口取数据、渲染卡片列表、处理分页、处理搜索条件。组件划分上,建议列表页只做数据获取和状态管理,游戏卡片抽成一个小组件。
<template> <div class="game-list"> <el-radio-group v-model="categoryId" @change="handleSearch"> <el-radio-button :value="0">全部</el-radio-button> <el-radio-button v-for="cat in categories" :key="cat.id" :value="cat.id"> {{ cat.name }} </el-radio-button> </el-radio-group> <el-row :gutter="16"> <el-col :span="6" v-for="game in gameList" :key="game.id"> <GameCard :game="game" /> </el-col> </el-row> <el-pagination v-model:current-page="page" :page-size="pageSize" :total="total" layout="prev, pager, next" @current-change="loadList" /> </div> </template>el-pagination 的 current-page 和 page-size 要绑定到 data 里,切换页码时触发 loadList 重新请求。这里很多人会踩的一个问题是把 total 写死成当前返回列表的长度,导致只显示一页。total 必须是后端返回的总记录数,而不是列表数组的长度。
GameCard 组件只负责接收一个 game 对象并渲染封面和标题,点击后跳转到详情页。这样列表页和以后可能有的"收藏列表"可以复用同一个卡片组件。详情页则负责三件事:调游戏详情接口、展示信息、加载评论列表和发表评论。
// 详情页加载游戏信息 const loadDetail = async () => { const id = route.params.id const data = await getGameDetail(id) game.value = data loadComments() }详情页的 viewCount 字段后端在返回前会自动加一,前端无需处理。评论功能是详情页的自然延伸,评论列表和游戏详情分开两个接口,避免每次打开详情都拉一堆评论数据影响速度。
5. 前后端联调避坑:游戏分享网站 5 个经典问题和排查顺序
从源码解压到把网站跑起来,中间有一串问题几乎每个做这个项目的人都会遇到。这些问题单独看都不难,但凑在一起很容易让人怀疑是代码问题,实际却是环境配置问题。
5.1 前端请求后端报跨域:现象、原因与解决
现象是浏览器控制台报 "Access-Control-Allow-Origin" 错误,接口在 Postman 里能正常返回,但前端页面请求失败。
原因有两层。第一层是后端没有开启 CORS;第二层是即使后端开了 CORS,拦截器在 preHandle 阶段就拦截了 OPTIONS 预检请求,导致真正的请求发不出去。只加 CORS 配置不管拦截器的问题,就会看到"前端报跨域,后端日志还没有新请求"的怪象。
解决方式是后端加一个 CORS 配置类,同时确保拦截器放行 OPTIONS。
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/api/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }allowedOriginPatterns 用 * 在本地开发阶段没问题,但如果网站要上线,建议改成具体的域名,否则任何网站都能跨域调用接口,存在一定安全隐患。
5.2 登录后刷新页面就退出:token 存储位置错了
现象是登录成功跳转首页,一切正常,但一刷新浏览器就跳回登录页。
原因是登录接口返回的 token 被放在 Vuex 或组件的 data 里,没有持久化。vuex 的数据存在内存中,刷新页面内存释放,token 跟着没了。
解决方式是在登录完成后把 token 同步写入 localStorage。axios 请求拦截器从 localStorage 拿 token,这样无论页面刷新多少次,只要 localStorage 还有值,登录态就在。logout 时才调用 localStorage.removeItem('token')。
5.3 上传的图片访问 404:静态资源映射没配
现象是上传接口返回了图片路径,浏览器直接访问这个路径却返回 404。
原因是前端上传根路径是本地文件路径,需要映射成可访问的虚拟 URL。Spring Boot 默认只映射 classpath 下的静态资源,项目启动后新增的外部文件目录不会自动暴露。
解决方式是加上前面 WebMvcConfig 中 addResourceHandlers 的配置。要重点确认磁盘路径和请求 URL 的对应关系:请求http://localhost:8080/upload/xxx.jpg,映射到服务器上实际的 upload 目录。路径最后一定要带/,否则映射规则匹配不到子目录。
5.4 MySQL 中文乱码:建库字符集一路没设对
现象是数据库里中文正常,但页面展示出现问号,或者数据库直接报 "Incorrect string value" 错误。
原因是建库、建表、连接串三个环节里至少有一处字符集不对。只改了建库的字符集而连接串没设置,写入时依然可能乱。
解决方式是控制好三条链路。建库时指定DEFAULT CHARACTER SET utf8mb4,建表时不写字符集继承自库,连接串加characterEncoding=utf8和useSSL=false。
spring.datasource.url=jdbc:mysql://localhost:3306/game_share?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/ShanghaiserverTimezone 也建议显式指定,否则 MySQL 8.x 的默认时区和本地时区不一致,会导致时间字段相差八个时区。
5.5 分页数据显示异常:pageSize 参数被后端映射错误
现象是列表页设置了每页显示 8 条,但实际只显示 4 条,或者翻页后数据重复。
原因通常是前端传的 pageSize 名称和后端 Controller 的参数名不一致。很多 Native 查询或者 MyBatis-Plus 分页插件里对参数名有要求,如果后端用 @RequestParam 接收limit而前端传的是pageSize,值就对不上。
解决方式是统一分页参数命名。整个项目里就固定用 page 和 pageSize 两个参数名,前端请求封装、Controller 接收、分页对象封装都用同一个名字。排查时要先看 Network 面板里实际发出的参数名,再看后端方法签名,对齐后问题自然消失。
6. 验收前做一次全链路验证:从建库到评论跑通的标准流程
源码跑通只是第一步,答辩时现场出问题才是最难受的。我习惯在交付前按"干净环境"的标准完整走一遍:删掉数据库、停掉后端、删掉前端 node_modules,然后从零开始。这样做能暴露环境依赖问题,而不是让代码带着运气运行。
流程第一步是执行初始化 SQL 脚本建库建表。用 Navicat 或命令行执行都行,执行完后检查四张核心表是否都创建成功,user 表里有没有初始管理员账号。第二步启动后端,IDEA 里运行主类,观察日志里有没有报端口被占用或数据源连接失败的错。第三步启动前端,npm install 之后 npm run dev,浏览器打开登录页,用初始管理员账号登录,能看到控制台没有 401 或跨域报错就算通过。
功能验证按主流程来:发布一个游戏,上传封面,看列表页能否刷出来;点进详情页,确认浏览量加一;发表一条评论,刷新页面看评论是否还在;换一个普通用户账号登录,验证它不能访问后台管理页面。这套链路走通,系统的主干功能就没有大问题。
我在提交这类项目前还有一个习惯:打开前端打包配置检查两个地方。一是路由模式用的 history,部署到 Nginx 后需要配置 try_files 来兜底。二是接口地址不能写死成 localhost,否则打包后换台机器就打不开。这两个问题在源码的基础上几乎必然出现,提前处理能省掉很多临场麻烦。
location / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; }整个项目做完回看,最值得投入精力的不是把某个页面做得多么花哨,而是把登录鉴权、数据关联和部署路径这三件事想清楚。它们是这类系统的骨架,也是答辩时评委最常追问的地方。希望这篇能帮你在拿到源码后少走弯路,把精力放到真正能体现出理解和工程能力的地方,祝顺利。
本文还有配套的精品资源,点击获取