资讯动态

SpringBoot+Vue+MyBatis+MySQL 打造网游推荐网站:从设计到部署全解析

发布时间:2026/10/24 5:49:39 来源:尧图企业网站定制
前阵子一个做游戏社区的朋友找我抱怨说他们站里新玩家问得最多的问题永远是最近有什么好玩的网游而市面上的推荐站不是广告太多就是评分虚高真正按玩家口碑排的没几个。聊完我突发奇想与其找现成方案不如自己动手写一个前后端分离的网游推荐网站。技术栈就锁定了标题里的四件套——SpringBoot 做后端 APIVue 写前端界面MyBatis 管数据库操作MySQL 做存储前后端通过 JSON 交互整个项目从零到部署上线我用了三周。这篇文章不是教你背概念而是把整个系统怎么搭、接口怎么设计、热门排序怎么算、部署踩了哪些坑完完整整捋一遍。如果你正在学 SpringBoot 和 Vue想找一个带完整源码和部署教程的项目练手或者你是某个游戏社区的技术负责人想快速搭一个游戏推荐模块这篇都适合你。下面我按真实的开发顺序来拆解。1. 需求拆解与技术选型为什么这套组合最省心1.1 游戏推荐网站到底要解决什么问题先别急着写代码想清楚这个网站是给谁用的。我调研了一圈玩家社区发现核心需求其实就五条打开首页就能看到当下最热门的网游不用自己翻论坛。能按类型筛选比如 MMO、MOBA、射击、休闲各看各的。能搜索指定游戏看到详细资料和玩家评分。看中某个游戏后可以收藏下次直接进个人中心找。玩过的玩家能打分、写短评给后来人参考。这五条拆成功能模块就是推荐页、分类列表页、搜索、游戏详情页、收藏夹、评价系统。再加一个简单的后台管理页面用来录入游戏资料和重置热门数据基本就完整了。这里我特别想强调一个容易被新手忽略的点推荐网站的核心不是展示游戏而是排序。同样一批游戏为什么某款排在前面依据是什么这个排序规则才是整个系统的灵魂后面我会专门讲我用的热度算法。1.2 SpringBoot Vue MyBatis MySQL 的组合逻辑技术选型上我没怎么纠结因为这四样配合起来非常经典每一环都有明确理由。SpringBoot 提供的价值是快速搭建 REST API。它内嵌了 Tomcat一个RestController注解就能把 Java 方法暴露成 HTTP 接口省去大量 XML 配置。做前后端分离项目时后端只需要关心返回什么 JSON 给前端SpringBoot 的生态让这件事变得很自然社区资料也多遇到问题一搜一大把。Vue 这边选它是因为组件化开发适合这种页面多、交互多的场景。推荐页、列表页、详情页各自是独立组件互不干扰。而且 Vue 的生态对这种中后台类前端项目非常友好配合 Vue Router 做路由跳转、Pinia 做状态管理逻辑很清晰。MyBatis 是我刻意选的。很多教学项目用 JPA但游戏推荐网站里有大量动态查询和统计 SQL——比如按热度排序、多条件筛选、关联查收藏数等——MyBatis 允许手写 SQL对这类复杂查询控制力最强。你可以用whereif动态拼接条件而不是像 JPA 那样在方法名上做文章。刚开始可能觉得写 SQL 麻烦但真正调优的时候手写 SQL 的效率优势就体现出来了。MySQL 就不用多说了开源、稳定、使用成本低对中小型推荐站来说性能完全够。做个简单对比大家就明白我的取舍了对比项选择方案备选方案我选它的原因后端框架SpringBootSSM 手搭、Node.js内嵌容器、起步快、资料全ORM 层MyBatisSpring Data JPA复杂 SQL 可控、性能调优直接前端框架Vue 2/3React模板语法上手快、组件化够用数据库MySQLPostgreSQL生态成熟、部署简单、团队都会1.3 项目目录结构项目采用标准的前后端分离目录代码分两个顶层文件夹管理养成习惯后维护成本很低game-recommend/ ├── backend/ # SpringBoot 后端 │ ├── src/main/java/ │ │ └── com/demo/game/ │ │ ├── controller/ # 接口层 │ │ ├── service/ # 业务逻辑层 │ │ ├── mapper/ # MyBatis 数据访问层 │ │ ├── entity/ # 实体类 │ │ ├── dto/ # 数据传输对象 │ │ ├── config/ # 跨域、拦截器配置 │ │ └── common/ # 统一返回结果、异常处理 │ └── src/main/resources/ │ ├── mapper/ # MyBatis XML 文件 │ ├── application.yml │ └── sql/init.sql # 建表语句 └── frontend/ # Vue 前端 ├── src/ │ ├── api/ # 封装 Axios 请求 │ ├── router/ # 路由配置 │ ├── views/ # 页面组件 │ ├── components/ # 公共组件 │ ├── store/ # Pinia 状态管理 │ └── main.js └── vite.config.js # 开发环境代理这样划分的用意是后端严格分层前端按页面和功能拆组件新人接手后看目录就能把整个系统摸个七八成。2. 后端实现从建表到热门推荐排序2.1 五张核心表的设计与关系数据库设计是整个系统的基础我的经验是字段宁多勿乱关系宁简勿繁。这个项目一共建了五张表分别是游戏表、分类表、用户表、收藏表、评论表。游戏表games是核心表字段包括字段名类型说明idbigint主键雪花IDnamevarchar(100)游戏名称category_idbigint所属分类ID关联分类表cover_urlvarchar(255)封面图地址descriptiontext游戏简介pvbigint浏览次数用于热度计算recommend_weightdecimal(5,2)运营人工加权分statustinyint1上架 0下架created_atdatetime创建时间updated_atdatetime更新时间其他四张表的逻辑比较常规分类表categories存类型名称和排序号用户表users存账号密码和昵称头像收藏表favorites是用户和游戏的多对多关联表还有created_at记录收藏时间评论表reviews存评分1到5分、评价内容、用户ID关联游戏ID。这里我特别想说一个细节评分为什么不直接存在游戏表里而是单独用一张评论表因为评分是一个统计值如果直接存总数每次玩家打分都要更新游戏表并发高时会锁行拆成评论表后热门排序时可以实时COUNT、AVG数据也更灵活。2.2 热门推荐的排序逻辑这个模块是整个项目最核心的部分。首页推荐列表不能只按游戏ID倒序排那样新人总会排在前面。我实现的热度算法用了一个综合公式热度分 收藏数 × 0.4 评论数 × 0.3 近7天浏览PV × 0.2 运营加权分 × 10同时为了防止旧游戏永远霸占榜首加了一个时间衰减因子发布时间距离今天每多7天热度分衰减5%。这样既保证老游戏有积累优势也给新游戏留了爬升空间。在 MyBatis 里对应一条动态 SQL核心片段长这样SELECT g.id, g.name, g.cover_url, g.description, g.pv, (COUNT(DISTINCT f.id) * 0.4 COUNT(DISTINCT r.id) * 0.3 g.pv * 0.2 g.recommend_weight * 10) * POWER(0.95, DATEDIFF(NOW(), g.created_at) / 7) AS hot_score FROM games g LEFT JOIN favorites f ON g.id f.game_id LEFT JOIN reviews r ON g.id r.game_id WHERE g.status 1 GROUP BY g.id ORDER BY hot_score DESC LIMIT #{limit}这条 SQL 顺着读就能理解把收藏数、评论数、浏览量、加权分按不同权重加起来再用时间衰减系数打折最后倒序输出。用LEFT JOIN而非INNER JOIN是为了保证一个没有任何收藏和评论的新游戏也能出现在结果里只是热度分比较低不至于被过滤掉。冷启动阶段是个需要特别处理的场景。网站刚上线时库里可能只有几百条游戏数据和几个测试评论上述公式算出来的热度分近乎于零。我当时的做法是当总评论数小于一个阈值比如 50时首页直接改为按分类多样 发布时间新 人工加权重来排序保证每个分类至少有一个代表游戏出现在首屏。这一步很关键否则新站首页就会推荐出一堆零互动游戏体验很差。2.3 Controller、Service、Mapper 三层如何协同后端接口设计遵循 SpringBoot 通用的三层模式Controller 接收请求并做参数校验Service 处理业务逻辑Mapper 通过 MyBatis 访问数据库。我设计的核心接口如下方法路径说明GET/api/games/hot首页热门推荐按热度分排序GET/api/games/category/{id}按分类查列表GET/api/games/search?keyword搜索GET/api/games/{id}游戏详情GET/api/games/{id}/reviews某游戏的全部评论POST/api/games/{id}/favorite收藏/取消收藏POST/api/games/{id}/review发表评分评论GET/api/user/favorites用户收藏列表注意接口路径的命名一致性很重要。我用的是资源式路径/api/games/{id}一眼能看出这是在操作某个具体的游戏资源前端调用时也比较好记。举例收藏接口的 Service 层逻辑大概是这样的Service public class FavoriteService { Autowired private FavoriteMapper favoriteMapper; public boolean toggleFavorite(Long userId, Long gameId) { Favorite existing favoriteMapper.findByUserIdAndGameId(userId, gameId); if (existing ! null) { favoriteMapper.deleteById(existing.getId()); return false; // 取消收藏 } else { Favorite fav new Favorite(); fav.setUserId(userId); fav.setGameId(gameId); favoriteMapper.insert(fav); return true; // 已收藏 } } }一个方法处理收藏和取消收藏两种操作前端只需要判断返回值是true还是false来切换按钮状态非常简单。2.4 MyBatis 动态 SQL 在搜索筛选里的用法搜索功能如果只按一个关键词查标题用简单的WHERE name LIKE就够。但实际的筛选需要组合条件玩家可能同时选择MMO 类型、评分 4 分以上、只看有中文这几个条件。这时 MyBatis 的动态 SQL 就派上用场了。select idsearchGames resultTypecom.demo.game.dto.GameDTO SELECT g.*, AVG(r.score) AS avg_score FROM games g LEFT JOIN reviews r ON g.id r.game_id where if testcategoryId ! null AND g.category_id #{categoryId} /if if testkeyword ! null and keyword ! AND (g.name LIKE CONCAT(%, #{keyword}, %)) /if if testminScore ! null GROUP BY g.id HAVING AVG(r.score) gt; #{minScore} /if /where ORDER BY g.pv DESC /select这里最需要注意的是#{}的使用。MyBatis 中#{}是预编译参数占位符能防止 SQL 注入而${}是字符串拼接存在安全隐患。凡是玩家传入的搜索词、分类ID一律用#{}绝不用${}。这个原则我之前在别的项目里吃过亏所以在这里养成了习惯。3. 前端 Vue 部分组件拆分与推荐页渲染3.1 路由与页面骨架前端页面结构我规划了五个视图首页推荐、分类列表、搜索页、游戏详情、个人中心。Vue Router 配置如下const routes [ { path: /, name: Home, component: () import(/views/Home.vue) }, { path: /category/:id, name: Category, component: () import(/views/Category.vue) }, { path: /search, name: Search, component: () import(/views/Search.vue) }, { path: /game/:id, name: GameDetail, component: () import(/views/GameDetail.vue) }, { path: /user, name: UserCenter, component: () import(/views/UserCenter.vue) }, ]组件使用() import()实现路由懒加载打包后每个页面单独生成 JS 文件首屏只加载首页需要的代码这个优化在低配服务器上效果尤其明显。3.2 Axios 统一封装前后端分离后前端所有 HTTP 请求都走 Axios。如果每个页面都写一遍请求配置后期维护会想哭。我封装了一个统一的请求模块import axios from axios const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, 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) window.location.href /login } return Promise.reject(error) } ) export default request这段代码里有三个细节值得留意。第一baseURL用了环境变量VITE_API_BASE_URL在开发环境指向/api让 Vite 代理转发生产环境构建时替换成服务器的正式域名不用改代码。第二请求拦截器统一加 token避免每个接口手动传。第三响应拦截器里把response.data直接返回这样业务代码拿到的直接就是后端 JSON 格式少一层嵌套。3.3 推荐页的性能优化骨架屏与懒加载推荐页是门面加载速度直接影响第一印象。我做了一个骨架屏组件在数据没返回前展示灰色的卡片占位数据返回后用真实内容替换。这样用户不会看到白屏感知加载速度会快很多。图片懒加载用 Vue 指令实现只有当图片进入可视区域时才把>// store/favorite.js export const useFavoriteStore defineStore(favorite, { state: () ({ favoriteMap: {} // gameId - boolean }), actions: { async toggle(gameId) { const res await request.post(/api/games/${gameId}/favorite) this.favoriteMap[gameId] res.data return res.data } } })这样设计的好处是详情页、列表页、推荐页只要用到有没有收藏这个状态都从同一个 store 读取一旦切换状态所有页面同步刷新不会出现收藏了一个游戏但回到列表依然是未收藏的尴尬。4. 联调阶段的高频坑位与排查路径前后端分离开发最刺激的就是联调阶段。明明后端接口自己测没问题前端一调就是一堆错。这一章把我踩过的坑和排查思路完整写出来你照着检查能省至少一天时间。4.1 跨域配置到底错在哪第一个坑是跨域。开发环境前端跑在 5173 端口Vite 默认后端跑在 8080 端口两者端口不同浏览器就会拦截跨域请求报CORS error。我先在后端加了全局跨域配置Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/api/**) .allowedOrigins(http://localhost:5173) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true); } }同时在前端 Vite 里配置了开发代理// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这两条缺一不可。CorsConfig解决的是后端允许跨域Vite 代理解决的是前端请求转发。如果两个都配了开发环境所有/api开头的请求走代理到后端完全不会触发浏览器的跨域限制。注意这里allowedOrigins生产环境要改成正式域名用*配合allowCredentials(true)会报错因为凭证模式不允许通配源。4.2 时间字段和 Long 型 ID 的格式陷阱联调时遇到的第二个坑很隐蔽前端拿到的游戏 ID 最后几位变成了 0。我用的雪花 ID 是 19 位 Long 型远超 JavaScriptNumber能安全表达的范围2 的 53 次方浏览器解析 JSON 时精度丢失导致 ID 不准确。解决方案是让后端把 ID 序列化为字符串返回。在 SpringBoot 里给 Long 类型加一个注解或者配置全局 Jackson 策略Configuration public class JacksonConfig { Bean public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() { return builder - { builder.serializerByType(Long.class, ToStringSerializer.instance); builder.serializerByType(Long.TYPE, ToStringSerializer.instance); }; } }同理时间字段也要统一格式。我做了全局yyyy-MM-dd HH:mm:ss格式化否则后端默认返回的 ISO 时间字符串前端处理起来很啰嗦。注意这个问题在前后端分离项目中极其常见。一旦发现前端 ID 传回后端后数据对不上第一反应就检查 Long 精度。4.3 分页参数和搜索条件的参数名不一致第三个坑是接口参数名前后端没对齐。后端分页用的是pageNum和pageSize前端某个页面却写了page和size结果数据永远分页不正确。这类问题通常不是技术难度而是沟通成本。我后来给自己定了个规矩所有分页接口统一用pageNum/pageSize所有响应统一包一层{ code, message, data }前端封装一个getPage公共函数把两个参数自动加上杜绝手抄参数名。4.4 预检请求与 404 的完整排查链路最后一个高级一点的坑前端调用POST接口时后端返回了 404但浏览器 Network 面板里看到网络请求名是OPTIONS。我当时的排查链路是这样的先在浏览器按 F12 打开 Network 面板看到请求方法显示OPTIONS瞬间明白这是跨域预检请求。浏览器在发送真正的POST前会先发送一个OPTIONS请求询问服务器是否允许跨域。如果后端只配置了GET/POST方法没加OPTIONS或者过滤器拦截了OPTIONS请求后端就可能返回 404导致真正的请求永远不会发出。解决方式是在跨域配置或警惕网关层将OPTIONS请求直接放行并在allowedMethods里显式加上OPTIONS。这就是很多前后端联调 404 问题的真实元凶排查顺序一定是先看请求方法再看响应状态码最后才去看业务代码。5. 部署上线从 jar 包到 Nginx 反向代理本地开发完只是第一步真正让别人能用才是完整项目。部署阶段我记录了一套完整流程照着做就能跑起来。5.1 后端打包与环境配置后端环境要区分开发和生产我用 Maven Profile 管理。在工程的application.yml里写公共配置然后在application-dev.yml和application-prod.yml里分别覆盖数据库地址和日志级别。生产环境数据库连接信息不能写死在仓库里我用的方式是配置环境变量spring: datasource: url: jdbc:mysql://${DB_HOST}:${DB_PORT}/${DB_NAME}?useUnicodetruecharacterEncodingutf8 username: ${DB_USERNAME} password: ${DB_PASSWORD}打包命令很简单mvn clean package -DskipTests -Pprod打出的 jar 包在target/目录上传到服务器后启动nohup java -jar game-recommend.jar --spring.profiles.activeprod /logs/game.log 21 nohup是为了让进程在 SSH 断开后继续运行日志落到/logs/game.log方便排查。第一次启动如果报连不上数据库不要急着改代码先用telnet或云服务商的安全组规则确认 3306 端口是否放行这个我看身边人踩过无数次。5.2 Vue 构建和 Nginx 配置前端构建更简单npm run build产物在dist/目录把它上传到服务器任意静态目录比如/var/www/game。之后配置 Nginx 站点server { listen 80; server_name example.com; root /var/www/game; index index.html; # 前端路由 history 模式 location / { 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; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # 静态资源缓存 location /assets/ { expires 7d; add_header Cache-Control public, max-age604800; } }这里有几个关键点。root /var/www/game是把前端构建产物作为站点根目录try_files $uri $uri/ /index.html解决 Vue Router history 模式下刷新页面 404 的问题proxy_pass把/api前缀的请求转发给后端 SpringBoot这样浏览器始终只和 Nginx 一个域名通信不会再产生跨域困扰。生产环境强烈建议让此域名开启 HTTPS用证书自动续期工具配置起来也很快。如果只想自己内网体验HTTP 也能跑通但对外发布时 HTTPS 基本是标配。5.3 数据库初始化与上线体检清单上线前数据库初始化我做了两件事把init.sql导入 MySQL然后手动插入几条测试数据用于验证热门排名算法是否正常工作。上线后我习惯跑一遍体检清单浏览器打开首页确认推荐列表有数据且按热度排序。点击分类、搜索关键词确认筛选和搜索接口正常。登录、收藏、评论一条龙测试确认状态互斥逻辑正确。查看/logs/game.log确认无异常堆栈。确认 Nginx 错误日志为空前端资源请求无 404。定时备份数据库mysqldump -u root -p dbname backup.sql设置 crontab 每天凌晨执行。部署这事看着繁琐但就是这些重复劳动构成了可靠的系统根基。我一开始也嫌麻烦后来有一次服务器系统盘挂了靠备份十分钟还原从此养成习惯。最后再分享一个真实的体会整个项目做下来最让我意外的不是技术问题而是冷启动推荐这件事。我本以为热度排序算法写出来就万事大吉结果网站上线第一天因为互动数据为零推荐页排序基本是随机的新用户点进去看到的前十个游戏跟他完全不相关。后来我临时在管理后台加了一个人工置顶功能运营每周手动挑三到四款新游戏放在首页推荐位才把冷启动阶段的体验救回来。如果你也要做同类网站我强烈建议从一开始就把人工干预的机制设计进去不要天真地以为算法能解决一切。另外联调阶段那四个坑——跨域、Long 精度、参数名不一致、OPTIONS 预检——虽然每个坑单独看都不难但叠加在一起的排查成本相当高。把这篇文章收藏起来照着清单走一遍能省下不少头发。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑