《战舰世界》的老玩家应该都有过类似的体验:想查一艘巡洋舰的装甲中板厚度、主炮射程、隐蔽系数,客户端里要翻好几层菜单,外部的百科站点数据又未必跟得上版本。这个项目的动力很朴素——用 Spring Boot + Vue 自建一套 Javaweb 方向的《战舰世界》游戏百科信息系统,把舰船图鉴、攻略资料、收藏、评论全部收进一个网站,并配套整理源码、部署文档、代码讲解。整个项目从建库到上线,我完整跑过一遍,下面讲的每一步都是可复刻的,不是概念方案。
1. 项目缘起:给《战舰世界》玩家一座自己说了算的资料库
1.1 玩家找数据到底难在哪
先说痛点。游戏客户端里的舰船数据其实很全,但分类入口是按科技树来的,想看某艘船的隐蔽系数和船长技能收益,得点进舰船详情再层层翻。外网百科当年还能凑合用,但版本更新后新船、平衡性调整经常滞后;国内站点要么广告太多,要么资料停留在老版本。我当时的需求很简单:打开一个网页就能看到所有舰船,按阵营、舰种、等级筛选,输入名字直接搜,点进去是完整参数,顺手还能看攻略、发评论。于是决定自己做一个百科信息系统,数据以游戏公开资料和玩家社区整理为准,自己维护,不用再看别人的脸色。
这个定位决定了系统不大,但五脏得全。一个访客端要能完成浏览、搜索、筛选、详情查看、收藏、评论;一个管理端要能录入舰船、维护参数、发布攻略文章。用户体系不需要复杂,能区分普通用户和管理员就行。
1.2 技术选型:为什么是Spring Boot + Vue而不是JSP
选型上基本没有纠结。后端用 Spring Boot,这是现在 JavaWeb 方向的主流演进形态,Spring Boot 内部其实还是 JavaWeb 那套 Servlet 模型,控制器、过滤器、拦截器这些概念都继承了下来,只是把配置自动化处理了。对于想走 Web 开发的人,学 Spring Boot 比死记 SSM 的 XML 配置要有用得多。
前端用 Vue,理由也很简单:这个场景里很多交互需要异步刷新,比如筛选条件联动、搜索防抖、评论局部更新,传统的 JSP/Thymeleaf 这套服务端渲染要反复刷新页面,代码写起来也绕。Vue 组件化之后,列表页、筛选器、详情页各管各的状态,开发体验好一个档次。项目最后选定 Spring Boot 2.7 + Vue 3 + Vite + MySQL 8,没有引入特别重的东西,单机部署也没压力。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 需求拆解与数据库设计:六张表撑起一个百科站
2.1 从用户流程反推功能清单
动手写代码前,我把功能按用户路径拆了一遍。访客打开首页,能看到热门舰船和最新攻略;进入图鉴页,通过筛选器找到目标船;点击卡片进入详情页,查看完整参数、相关推荐、评论区;对感兴趣的船可以收藏,对攻略可以评论。管理员登录后,进入后台维护舰船数据、发布文章。
这个流程对应到后端就是几组接口:舰船的列表查询和详情查询、文章的列表和详情、收藏的新增删除、评论的发布和展示。功能不夸张,但覆盖了一个完整信息系统该有的闭环。
2.2 ship 表:舰船属性字段该怎么存
最核心的表是 ship。它承载了百科站几乎全部业务价值,字段设计直接影响筛选和展示效果。
实际建表时,我把舰船属性分成几类:基础信息(名称、阵营、舰种、等级)、机动属性(最高航速、舵效)、火力属性(主炮口径、主炮射速、主炮射程、鱼雷射程)、防护属性(生命值、装甲带厚度、装甲甲板厚度)、隐蔽属性(水面隐蔽、空中隐蔽)。建表结构大致是:
sql复制CREATE TABLE `ship` (
`id` bigint NOT NULL AUTO_INCREMENT,
`name` varchar(64) NOT NULL,
`nation` varchar(32) NOT NULL,
`type` varchar(32) NOT NULL,
`tier` int NOT NULL,
`hp` int DEFAULT NULL,
`max_speed` decimal(5,1) DEFAULT NULL,
`main_gun_caliber` int DEFAULT NULL,
`main_battery_range` decimal(6,1) DEFAULT NULL,
`surface_detectability` decimal(5,1) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_nation_type_tier` (`nation`, `type`, `tier`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
这些字段尽量用数值类型存,而不是塞进一个文本描述,因为后续列表页要做排序、筛选、对比,只有结构化数据才扛得住这些操作。
举个例子:筛选中选“巡洋舰 + 8级 + 射程大于15公里”,如果射程存在 description 文本里,这查询就没法写。数值字段的另一个好处是详情页能直接做雷达图、属性对比,前端不用自己解析字符串。
2.3 攻略、收藏、评论:衍生模块的取舍
辅助表我最终做了五张:article、comment、favorite、user,加上前面的 ship。article 表除了标题、正文、分类、作者、发布时间之外,还加了一个 ship_id 字段,用来做详情页和攻略的关联推荐。比如玩家在看某艘巡洋舰详情时,侧栏能列出“这艘船的攻略打法”,这比单纯堆文章列表有价值。
comment 表设计了 parent_id,虽然是两级评论就够用了,但留一层自关联,以后做楼中楼不需要改表。favorite 表存用户和舰船的关联,唯一索引直接建在 (user_id, ship_id) 上,防止重复收藏。user 表的字段也很克制,没做独立权限表,一个 role 字段区分普通用户和管理员就行。
数据来源方面,我建议不要依赖爬虫去抓来路不明的数据,法律风险先不论,质量也没法保证。我的做法是:以游戏内公开数据和社区公认的数据为准,先录入一部分常用舰船,另一部分靠管理后台持续补充。做百科系统的本质是内容运营,不是技术秀。
3. Spring Boot后端的接口落地:从实体映射到统一返回结构
3.1 后端工程分层与建包顺序
后端我用的是经典三层结构:controller 处理请求参数和响应,service 写业务逻辑,mapper 负责数据库操作,entity 存放实体。common 包里放统一返回体 Result、分页结果 PageResult、全局异常处理器。建包顺序建议从 entity 和 common 开始,先把数据模型和返回协议定好,再往上写 service 和 controller。
为什么这样分?因为一个小型系统虽然代码量不大,但接口多了以后,逻辑往 controller 里堆会非常痛苦。比如一个舰船筛选接口,条件组装、分页、多表关联都写在 controller 里,调试一次要翻半天。拆到 service 层之后,controller 只做参数校验和结果返回,后面加需求只改 service,职责清楚。
3.2 查询接口:分页、筛选、搜索一次说清
舰船列表接口是核心,长这样:
java复制@GetMapping("/api/ships")
public Result<PageResult<ShipVO>> list(
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "12") int size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String nation,
@RequestParam(required = false) String type,
@RequestParam(required = false) Integer tier) {
return Result.success(shipService.pageQuery(
new ShipQuery(page, size, keyword, nation, type, tier)));
}
service 里用 MyBatis-Plus 的 LambdaQueryWrapper 动态拼接条件,不需要为每个筛选组合写 SQL:
java复制LambdaQueryWrapper<Ship> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(StringUtils.hasText(nation), Ship::getNation, nation)
.eq(StringUtils.hasText(type), Ship::getType, type)
.eq(tier != null, Ship::getTier, tier)
.and(StringUtils.hasText(keyword), w -> w
.like(Ship::getName, keyword)
.or()
.like(Ship::getDescription, keyword));
Page<Ship> pageResult = shipMapper.selectPage(new Page<>(query.getPage(), query.getSize()), wrapper);
这里我用了 Page 分页插件,返回总条数和当前页数据,前端拿到之后自己渲染页数和卡片。注意 ShipQuery 是一个专门接收查询参数的 DTO,而不是把六个参数散着传,这样接口参数一多不会失控。
3.3 统一返回体、全局异常与跨域配置
接口对外统一返回 Result<T>,结构是 code、msg、data 三件套。前端 axios 拦截器里判断 code === 200 再放行,否则直接弹出错误信息,不用每个页面都写一遍错误处理。
全局异常处理这块我特别想强调一下。很多新手会在 controller 里到处 try-catch,然后发现异常处理代码比业务代码还多。正确做法是抛业务异常,由全局处理器统一转成响应:
java复制@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public Result<Void> handleBiz(BizException e) {
return Result.error(e.getCode(), e.getMessage());
}
@ExceptionHandler(Exception.class)
public Result<Void> handleGeneric(Exception e) {
log.error("系统异常", e);
return Result.error(500, "服务器开小差了");
}
}
跨域配置在开发阶段是绕不开的。Vue 跑在 5173 端口,后端跑在 8080 端口,前后端都被浏览器识别为跨域,开一个 CorsFilter 把跨域策略放行即可。等部署到 Nginx 用反向代理之后,前端访问 /api 时由 Nginx 转发到后端,变成同源请求,跨域配置其实就不怎么用到了。
4. Vue前端体验打磨:列表、筛选、详情三条核心路径
4.1 Vue项目初始化与路由骨架
前端我用 Vue 3 + Vite 初始化,路由采用 createWebHistory 方式,也就是 HTML5 History 模式,地址栏干净好看。路由结构分三组核心路径:首页展示热门舰船和最新攻略;图鉴页对应 /ships;详情页对应 /ships/:id。攻略列表和攻略详情也是独立的两个路由。这样设计的好处是和后端接口一一对应,URL 直达性很好,分享一个链接给对方,打开就是那艘船的详情。
组件层面我按页面和通用组件拆:页面视图放在 views 下,列表页里的筛选器、舰船卡片、分页条抽成组件,详情页里的参数表格、评论区也独立成组件。这样任何一个页面要调整,改动范围被限制在小组件内部,不会牵一发动全身。
4.2 列表页筛选器的交互实现
列表页是用户最常待的地方,交互核心是筛选器。我实现的筛选器由三个控件组成:舰种下拉、阵营下拉、等级选择,加上一个搜索框。筛选器组件通过 v-model 把选中值回传给父页面,父页面监听到变化后就重新调用接口拉数据。
搜索这里有个必须处理的细节——防抖。用户连续输入“岛”“岛风”两个字,如果每个字符都触发一次查询,体验会很差。我在监听器里加了一个定时器,输入停止 300 毫秒后才真正发起请求:
js复制let timer = null
function handleSearch() {
clearTimeout(timer)
timer = setTimeout(() => {
fetchShips()
}, 300)
}
页面拿回数据后,用卡片网格渲染舰船:封面图、舰名、阵营、舰种、等级、主炮射程这几个核心字段放在卡片正面。点击卡片跳转详情。分页条直接复用 Element Plus 的 Pagination 组件,page 和 size 变化时重新请求,记住把滚动条拉回顶部,否则翻页之后还在页面下部,体验很突兀。
4.3 详情页参数呈现与互动组件
详情页承载的是“深度信息量”。我的页面布局是头图区在上,下面是基础属性卡片,再往下是装甲区明细表格、鱼雷和主炮的详细数据,最后是相关攻略推荐和评论区。
属性呈现建议用表格而不是一串文字。玩家对比舰船的时候,一行一行看得更快,表格还能把“水上隐蔽”“空中隐蔽”这种枯燥数字变成可读的数据行。详情页里我留了一个收藏按钮,登录后点击调用收藏接口,已收藏的显示为高亮状态。评论区的逻辑是向后端拉取这条舰船的评论列表,提交评论后把最新数据回填到列表,不刷新整页。
图片加载这里要写个占位逻辑,不然外链图挂了,详情页会留下一块空白。我在组件里监听 error 事件,统一替换为占位图,这个小细节很影响观感。
4.4 开发阶段的前后端联调设置
本地开发阶段最省事的联调方案是 Vite 的代理,不用开启后端跨域也能跑通:
js复制server: {
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
前端请求写成 /api/ships,开发时走代理到后端,打包后走 Nginx 转发到后端,接口路径不用改一行代码。
5. 部署文档里的关键链路:本地跑通与服务器上线的差异
5.1 本地环境准备与启动三步曲
本地跑通这个项目,环境建议是 JDK 8 或 11、Maven 3.6 以上、MySQL 5.7 或 8。前端需要 Node 14 以上,Vite 对 Node 版本有一定要求,太旧的会提示依赖不兼容。
启动顺序我踩过几次坑之后固定下来了:先执行 init.sql 建库建表,再改 application.yml 里的数据库账号密码,然后启动后端 Java 进程,最后 npm run serve 启动前端。顺序颠倒会导致后端启动时报数据库连接异常,或者前端请求时接口 500,排查起来总觉得是代码问题,其实只是数据库没初始化。
5.2 构建产物与两种部署方式对比
部署前要把项目打成产物。后端执行:
bash复制mvn clean package -DskipTests
产物是 target/wows-wiki.jar。前端执行:
bash复制npm run build
产物是 dist 静态目录。有两种部署方式:
| 方式 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 合并部署 | 把 dist 文件复制到 Spring Boot 的 resources/static,重新打包 jar | 只有一个进程,运维简单 | 前端每次改动都要重新打 jar |
| 前后端分离 | jar 单独跑,dist 交给 Nginx 托管 | 前端动静分离,更新互不影响 | 多一个 Nginx 进程要维护 |
我推荐第二种,尤其做毕设或练手项目,Nginx 反向代理本身就是必练技能。
5.3 服务器上线:Nginx、Systemd、日志排查
服务器上我的目录规划是 /opt/wows-wiki/ 放 jar 包,/usr/share/nginx/html/dist 放前端静态文件。数据库连接串里的 IP 改成服务器内网或公网地址,然后用 Systemd 托管 Java 进程:
ini复制[Unit]
Description=wows-wiki-backend
After=network.target
[Service]
ExecStart=/usr/bin/java -jar /opt/wows-wiki/wows-wiki.jar
Restart=always
Environment=SPRING_PROFILES_ACTIVE=prod
[Install]
WantedBy=multi-user.target
Nginx 配置里最关键的是 try_files 和反向代理:
nginx复制server {
listen 80;
server_name your_server_ip;
root /usr/share/nginx/html/dist;
index 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;
}
location / {
try_files $uri $uri/ /index.html;
}
}
try_files 那行是解决刷新路由 404 的关键。因为 Vue 是 History 路由,前端路由 /ships/3 在服务器上并不存在真实文件,如果不回退到 index.html,刷新就白屏或 404。
5.4 上线之后看什么日志
上线后的调试思路,我总结成一条线:先看浏览器 Network 里的请求状态,如果是 404,基本是 Nginx 转发路径或前端路由回退配置不对;如果是 500,去后端看控制台堆栈日志;如果是连接超时,检查防火墙和端口是否开放。曾经有段时间接口偶尔 500,我盯着后端日志发现是数据库连接池和 MySQL 的 wait_timeout 不一致,空闲连接被数据库提前回收。后来在连接串里加了一条参数解决,这类问题只有真实部署过才会碰到,光看代码很难想到。
6. 代码讲解中我重点展开的三个设计点与后续扩展方向
6.1 值得反复看的代码细节
如果只看一遍代码,我建议重点看三块。
第一块是统一返回体和全局异常。理解 Result<T> 的协议约定,再看全局异常是怎么把所有错误收敛成统一响应的,这决定了代码里能不能少掉一半的重复判断。
第二块是 LambdaQueryWrapper 动态条件查询。它把“有参数就参与筛选,没参数就忽略”的常见场景用一套流畅的 API 表达清楚,比手写 XML 拼接 SQL 要直观得多。看懂之后,以后任何列表查询功能都能直接套这个思路。
第三块是 VO 和实体的分离。列表页只需要基础字段,详情页要多查一个百科描述和关联攻略,如果直接把实体返回给前端,要么数据不够用,要么把不需要的字段全暴露出去。服务层把实体组装成 VO,是后端设计成熟度的一个分水岭。
6.2 这个项目下一步还能往哪走
项目做完了,我也想过扩展方向。如果给自己加需求,我会先考虑三个方向:一是用 Redis 缓存舰船详情页,百科站的详情访问热度相对集中,缓存能明显降低数据库压力;二是加全文检索,文章越来越多之后,靠 SQL 的 LIKE 匹配不够用,可以引入搜索中间件;三是做舰船对比功能,两艘船并排展示参数雷达图,这个功能对玩家最有吸引力。
代码层面,当前架构给这些扩展预留了接口。详情页查询已经在 service 层做组装,加缓存时不用动 controller;文章列表的分页也是通用写法,接搜索中间件时把 service 内部实现替换掉就行。对一个练手项目而言,留好扩展口比堆功能更重要。
6.3 给准备复现的人几句实在话
最后给准备照着源码和部署文档复现的朋友几句实在话。第一,环境版本不要乱用最“新”的,稳定就好,我遇到过一位同学装了最新的 JDK 17 和 Spring Boot 3,结果很多依赖版本对不上,建议按部署文档里的版本走。第二,先把主流程跑通再补次要功能,舰船列表和详情页能查出来了,再考虑收藏和评论,否则一开始就卡在登录鉴权上,很容易劝退。第三,试着把部署文档里每一步都自己敲一遍,不要直接复制命令,敲的过程中你会注意到那些当初没在意的配置项,这些东西才是部署经验。
我从这个项目里收获最大的不是写了多少行代码,而是把需求、设计、编码、部署、文档串联成了一个整体认知。如果你也想练一个 JavaWeb 方向的项目,建议不要只抄源码,顺着这条链路从头到尾走一遍,做完之后再回头看课程作业里的那些页面,会觉得轻松很多。
