ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

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

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

前阵子一个做游戏社区的朋友找我抱怨,说他们站里新玩家问得最多的问题永远是"最近有什么好玩的网游",而市面上的推荐站不是广告太多,就是评分虚高,真正按玩家口碑排的没几个。聊完我突发奇想:与其找现成方案,不如自己动手写一个前后端分离的网游推荐网站。技术栈就锁定了标题里的四件套——SpringBoot 做后端 API,Vue 写前端界面,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,对这类复杂查询控制力最强。你可以用<where><if>动态拼接条件,而不是像 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主键,雪花ID
namevarchar(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 id="searchGames" resultType="com.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 test="categoryId != null"> AND g.category_id = #{categoryId} </if> <if test="keyword != null and keyword != ''"> AND (g.name LIKE CONCAT('%', #{keyword}, '%')) </if> <if test="minScore != 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}?useUnicode=true&characterEncoding=utf8 username: ${DB_USERNAME} password: ${DB_PASSWORD}

打包命令很简单:

mvn clean package -DskipTests -Pprod

打出的 jar 包在target/目录,上传到服务器后启动:

nohup java -jar game-recommend.jar --spring.profiles.active=prod > /logs/game.log 2>&1 &

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-age=604800"; } }

这里有几个关键点。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 预检——虽然每个坑单独看都不难,但叠加在一起的排查成本相当高。把这篇文章收藏起来,照着清单走一遍,能省下不少头发。

RELATED READING

延伸阅读

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