做了差不多两周时间,把这个基于 Spring Boot 的《战舰世界》游戏百科信息系统从零到一完整落地了。做这个项目的初衷其实挺朴素——游戏里舰船数据又杂又散,想查某条船的详细参数得翻好几个网站,于是干脆自己动手,用 JavaWeb 那套技术栈搭一个集中式的游戏百科,既能练手,又能真正解决查询痛点。如果你正在学 Spring Boot、想做 JavaWeb 完整项目案例,或者本身对《战舰世界》的数据感兴趣,这篇文章我会把整个项目从需求拆解、技术选型到数据库设计、核心代码实现、部署优化、踩坑记录全部写清楚,希望能给你一份可以直接照着做的参考资料。
1. 项目全貌:这个百科系统到底解决什么问题
1.1 从“游戏需求”到“系统需求”的转化过程
先说说最原始的需求。游戏里的舰船数据包括属性参数、历史背景、科技树关系,但官方 UI 和信息源头比较分散,玩家想对比不同舰船时效率很低。于是项目立项时我给自己定了三个核心目标:第一,把舰船基础信息统一管理起来,支持按国家、类型、等级等多维度筛选;第二,提供完整的舰船详情页,让用户能快速了解一艘船的来龙去脉;第三,做一个用户可以参与进来的系统,不能只是一个单向展示的静态网站。
这三句话落到系统上,就变成了很具体的技术需求。
数据层面,《战舰世界》的舰船信息天然有层级关系:国家之下有舰船类型,舰船类型之下才是具体的舰船条目。同时,舰船不是孤立的,它有历史背景、有同级对比、有关联的科技树信息。这些关系在关系型数据库里可以很自然地建模,这也成为我选择 MySQL 的直接理由。
功能层面,系统要区分游客、注册用户和管理员。游客只能浏览,注册用户可以收藏舰船、管理个人收藏夹;管理员负责维护舰船数据、审核内容。用户体系的存在意味着整个系统必须考虑登录态、权限控制、数据隔离,这一下子就把项目从“静态展示页”拉到了“完整 Web 应用”的复杂度。
这就是项目最初的定位:一个以数据展示为基础、带用户交互和管理后台的游戏百科信息系统。它不是最复杂的企业级应用,但麻雀虽小五脏俱全,JavaWeb 开发中的主流技术点基本都覆盖了。
1.2 功能模块拆分:把大需求拆成可落地的小模块
功能拆分我习惯用“角色 + 操作对象”的方式来做。把用户分成游客、注册用户、管理员三类,把操作对象分成舰船、收藏、系统管理三类,组合起来就是一张清晰的功能矩阵。
| 角色 | 可操作功能 | 关键权限点 |
|---|---|---|
| 游客 | 浏览舰船列表、查看舰船详情、按条件搜索 | 不能收藏、不能进入后台 |
| 注册用户 | 游客所有功能 + 收藏/取消收藏舰船、管理个人收藏夹 | 只能操作自己的收藏数据 |
| 管理员 | 用户所有功能 + 舰船管理、用户管理、数据统计 | 需要管理员角色标识 |
前台的核心模块是舰船检索与浏览。列表页要做成支持多条件筛选的,默认展示全部舰船,用户可以根据国家、舰船类型、等级组合筛选,还可以按照排水量、航速等属性排序。详情页要展示舰船大图、基础属性表格、历史背景介绍以及同类型其他舰船的推荐位。
后台管理模块相对常规但必不可少。舰船的新增、编辑、上下架,用户列表的查看与禁用,基础数据字典的管理。这部分的代码量其实不小,但逻辑相对直白,是标准的 CRUD 模式。
用户系统模块分为注册登录、会话管理、收藏夹三块。登录态我用了 JWT 方案,配合拦截器做接口权限控制,避免给每个 Controller 方法手动写权限校验代码。
这个功能矩阵明确了之后,整个项目的开发顺序也就出来了:先搭框架和数据库,再做前台展示,再做用户系统,最后做后台管理。先后台再前台也行,但我的习惯是先做前台展示,因为前台跑通了,数据模型的合理性就被验证了,后台管理只是对同一批数据做增删改查,反而更简单。
1.3 非功能性需求:性能、安全、可维护性
功能之外,还有一些看不见但很重要的需求。我总结成三点。
第一,查询性能。舰船列表页涉及多表关联查询,如果数据量上来之后不加控制,一个列表页可能拖垮数据库。所以数据库设计阶段就必须考虑索引,查询层还要加缓存。
第二,安全防护。系统涉及用户注册登录,密码不能明文存储,必须做加密处理;接口层面要防止未授权访问;后台管理接口必须做角色校验。
第三,可维护性。项目代码要分层清晰,Controller、Service、Mapper 各司其职,不能在 Controller 里堆业务逻辑,否则后续加功能会非常痛苦。
这些非功能性需求看起来虚,但直接影响后面的每一个技术决策。比如版本选择,比如是否引入 Redis,比如密码加密方案用什么算法。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型与落地:Spring Boot 为核心的技术栈搭建
2.1 Spring Boot 版本选择:别把版本追太高
这是整个项目里我踩过最多坑的地方,也是很多新手最容易忽略的地方。
Spring Boot 目前主流的两个大版本是 2.x 和 3.x。3.x 是较新的版本,底层基于 Spring Framework 6,强制要求 JDK 17 以上,整体性能和生态上更现代。2.x 目前仍然维护,最经典的 2.7.x 支持 JDK 8 到 JDK 21,兼容性最好。
我一开始图新鲜直接选了 Spring Boot 3.2,结果项目还没跑起来就被打脸了——很多第三方组件的 starter 还没适配到 3.x,尤其是部分 MyBatis 相关依赖、代码生成器和某些工具包的版本对不上,报错信息还特别隐晦。折腾了一个晚上之后,我老老实实切回了 2.7.18。
这里有句忠告:如果你做的是毕业设计或者练习项目,千万别盲目追新版本。选版本的核心标准不是“最新”,而是“你依赖的生态是否完全支持”。如果团队里别人用的是 2.7,你非要搞一个 3.2,两个人联调时就会遇到各种难以解释的问题。Spring Boot 的学习曲线本来就陡,不要让版本兼容性问题雪上加霜。
最终的版本组合是:
| 组件 | 版本 |
|---|---|
| Spring Boot | 2.7.18 |
| JDK | 1.8(对应 HotSpot 8) |
| MySQL | 5.7 |
| MyBatis-Plus | 3.5.3 |
| Hutool | 5.8.x |
| JWT | jjwt 0.9.1 |
这套组合非常成熟,网上案例多,出问题容易搜索到解决方案。
2.2 JDK 版本配套:为什么我坚持用 JDK 8
说到 JDK 版本,我知道现在很多教程都在推 JDK 17 甚至 21,但我在这个项目里坚持用了 JDK 8。原因很简单,Spring Boot 2.7 对 JDK 8 的适配最稳定,而且在真实的企业项目中,JDK 8 的存量系统仍然是海量的。
JDK 8 和新版本 JDK 最大的区别在于 javax 到 jakarta 的命名空间迁移。如果你选 Spring Boot 3.x,代码里要用 jakarta.servlet、jakarta.persistence 这些包,而 Spring Boot 2.x 用的还是 javax.*。这个差异对代码的影响非常大,在选型阶段就要确定,不要中途切换。
另外,如果项目最终要打包部署到老旧的服务器上,JDK 8 往往是最稳妥的。我这边的服务器系统是 CentOS 7,自带环境对 JDK 8 支持很好,如果用 JDK 17,还得额外处理一些系统库兼容性,没必要。
2.3 持久层选型:MyBatis-Plus 的优势和注意点
持久层我选了 MyBatis-Plus,而不是原生 MyBatis 或者 Spring Data JPA。理由很实际,这个项目里有大量单表 CRUD 和条件查询,MyBatis-Plus 的 BaseMapper 内置方法可以直接覆盖大部分需求,省去了手写 XML 的重复劳动;同时它保留了 MyBatis 手写 SQL 的灵活性,复杂多表查询可以在 XML 里精确控制。
举个小例子,舰船列表页的国家筛选、类型筛选、等级筛选,这些是典型的动态条件查询。在 MyBatis-Plus 里,用 LambdaQueryWrapper 可以直接拼接条件,完全不用写 XML:
java复制LambdaQueryWrapper<ShipInfo> wrapper = Wrappers.lambdaQuery();
if (StringUtils.isNotBlank(countryCode)) {
wrapper.eq(ShipInfo::getCountryCode, countryCode);
}
if (shipTypeId != null) {
wrapper.eq(ShipInfo::getShipTypeId, shipTypeId);
}
if (tier != null) {
wrapper.eq(ShipInfo::getTier, tier);
}
wrapper.orderByAsc(ShipInfo::getTier);
这段代码的语义一目了然,而且类型安全,编译期就能发现字段名写错的问题。
不过 MyBatis-Plus 也有坑。最大的坑是它默认的逻辑删除和字段自动填充功能,如果配置不当,可能会导致查询结果莫名缺失。我在项目里接了 MetaObjectHandler 做 createTime、updateTime 的自动填充,就有一次因为漏了 @TableField(fill = FieldFill.INSERT) 注解,导致插入数据时时间字段一直是 null。这个坑在后面的踩坑章节会详细说。
2.4 前端渲染方案:模板引擎还是前后端分离
前端方案上我纠结过一段时间。用 Thymeleaf 做服务端渲染,项目结构简单,部署方便,一个 Spring Boot 应用全搞定;用 Vue + 前后端分离,前端体验更好,但项目复杂度成倍增加,需要额外搭前端工程、处理跨域、做接口联调。
最终我选择了一个折中方案:前台页面用 Thymeleaf 做服务端渲染,后台管理页面用简单的 Vue 3 + Element Plus 做成前后端分离。这样前台可以快速上线,后台交互复杂、需要频繁刷新数据,用 Vue 组件化管理效率更高。
Thymeleaf 的好处是天然支持服务端模板,SEO 友好,搜索引擎可以抓取到舰船页面内容,对于百科类系统来说是加分项。它的语法也不复杂,比如在列表页渲染舰船名称和类型:
html复制<tr th:each="ship : ${page.records}">
<td th:text="${ship.name}">舰船名称</td>
<td th:text="${ship.shipTypeName}">舰船类型</td>
<td th:text="${ship.tier} + ' 级'">等级</td>
<td>
<a th:href="@{'/ship/' + ${ship.id}}" class="btn btn-sm btn-primary">查看详情</a>
</td>
</tr>
一开始花在 Thymeleaf 语法上的时间大概半天,熟悉之后效率很高。
为了前后台不冲突,我设置了不同的 URL 前缀:前台页面统一走 /,后台管理路径统一走 /admin/。Spring Boot 的视图解析器会自动定位到 templates 目录下的对应模板,这个设计在后面的路由配置中没有遇到任何冲突。
3. 数据库设计实战:舰船、用户、收藏的核心表结构
3.1 舰船信息表:一个符合游戏领域特征的模型
数据库设计是这个项目最核心的部分,因为所有功能最终都建立在数据模型上。舰船信息表是系统的绝对核心,我给它取名为 ship_info,字段覆盖了基础属性、战斗属性和展示属性,全部列出来供参考:
| 字段名 | 类型 | 允许为空 | 说明 |
|---|---|---|---|
| id | BIGINT | 否 | 主键,自增 |
| name | VARCHAR(100) | 否 | 舰船中文名称:如 “大和” |
| english_name | VARCHAR(150) | 是 | 英文名称:如 “Yamato” |
| country_code | VARCHAR(20) | 否 | 国家代码:US、JP、DE、GB、RU 等 |
| ship_type_id | TINYINT | 否 | 舰船类型ID,关联 ship_type 表 |
| tier | TINYINT | 否 | 等级,I 到 X 对应值 1-10 |
| displacement | DECIMAL(10,2) | 是 | 标准排水量,单位吨 |
| length | DECIMAL(8,2) | 是 | 舰船长度,单位米 |
| max_speed | DECIMAL(5,2) | 是 | 最大航速,单位节 |
| main_guns | VARCHAR(255) | 是 | 主炮配置描述,如 “3座3联装460mm/45倍径” |
| torpedo_config | VARCHAR(255) | 是 | 鱼雷配置描述 |
| aircraft_config | VARCHAR(255) | 是 | 舰载机配置描述,主要针对航空母舰 |
| description | TEXT | 是 | 舰船简要介绍 |
| history | TEXT | 是 | 历史背景详细信息 |
| cover_image | VARCHAR(255) | 是 | 封面图片URL |
| status | TINYINT | 否 | 状态:1 上架,0 下架 |
| view_count | INT | 否 | 浏览量 |
| create_time | DATETIME | 否 | 创建时间 |
| update_time | DATETIME | 否 | 更新时间 |
设计中有一个我特别强调的点:国家信息我用了 country_code 字符串而非独立的关联表。原因是《战舰世界》中的国家数量有限且稳定,用代码字符串足够,查询时不需要 JOIN,可以减少一次表关联。舰船类型是动态扩展的,所以单独建了表。
另一个关键设计是英文名称允许为空但中文名称必填。原因很实际,有些舰船在游戏里只有中文译名广泛流传,英文名反而查不到可靠来源。如果强制必填,会导致数据录入困难。
3.2 舰船类型表、用户表、收藏表的关联设计
舰船类型表 ship_type 结构很简单,常驻数据量几乎不会超过 10 条:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | TINYINT | 主键 |
| type_name | VARCHAR(50) | 类型名称:驱逐舰、巡洋舰、战列舰、航空母舰、潜艇 |
| type_code | VARCHAR(20) | 类型代码:DD、CL/CA、BB、CV/ACV、SS |
| sort_order | TINYINT | 排序值 |
用户表 user_info 和收藏表 user_favorite 是用户体系的基础。用户密码字段我用了 BCrypt 加密存储,不是明文也不是简单的 MD5。BCrypt 是一种自带盐值的哈希算法,每次加密同一个密码得到的密文都不同,即使数据库泄露,攻击者想要反向破解成本也极高。
收藏表是典型的多对多关联表,只保存业务主键关系:
| 字段名 | 类型 | 说明 |
|---|---|---|
| id | BIGINT | 主键 |
| user_id | BIGINT | 用户ID |
| ship_id | BIGINT | 舰船ID |
| create_time | DATETIME | 收藏时间 |
这个表不需要 update_time,因为收藏操作只有新增和删除,没有更新。
从索引优化角度看,收藏表的核心查询模式是“查某用户收藏的所有舰船”和“查某用户是否收藏了某艘舰船”,所以我在 user_id 上建了普通索引。联合索引 (user_id, ship_id) 也加了,因为查询收藏状态时通常伴随 where 条件同时出现。舰船表的 tier 和 ship_type_id 组合查询频率很高,也建了联合索引。
很多新手喜欢把所有字段都加上索引,这是错误的。索引不是越多越好,每多一个索引就会降低写入性能并占用磁盘空间。我只为核心查询路径建索引,这个度要把握好。
3.3 数据初始化:真实的游戏资料从哪来
数据库表结构建好了,接下来是数据初始化。很多练习项目都在这一步卡住,不愿意花时间维护数据,导致系统跑起来空荡荡的,展示效果差很多。
我花了大概五天时间整理了一百多条真实舰船数据,覆盖主流的战列舰、巡洋舰、驱逐舰和航空母舰。来源主要是游戏 Wiki 和公开资料,每条数据包括名称、国籍、类型、等级、排水量、主炮配置、历史背景等。
数据录入方式我推荐两种。如果数据量少,直接用 SQL 脚本插入,简单高效:
sql复制INSERT INTO ship_info (name, english_name, country_code, ship_type_id, tier, displacement, length, max_speed, main_guns, description, history, status, view_count, create_time, update_time)
VALUES ('大和', 'Yamato', 'JP', 3, 10, 65000.00, 263.00, 27.00, '3座3联装460mm/45倍径', '大和号是旧日本海军建造的最大战列舰...', '大和级战列舰的详细历史背景...', 1, 0, NOW(), NOW());
数据量大的话,可以考虑写一个数据导入的定时任务,从 JSON 文件批量解析入库。这个项目里数据量可控,所以我选择了 SQL 脚本方式,把一百多条 INSERT 语句放在一个 init-data.sql 文件中,方便重跑。
这里要特别提醒,description 和 history 字段不要偷懒。百科系统的核心价值就是内容深度,如果每艘船只有寥寥几个参数没有文字介绍,用户打开详情页会觉得毫无收获。我的做法是每条舰船至少写两段以上历史介绍,包括建造背景、服役经历、战史表现、最终结局,这些都是能吸引玩家反复查看的内容。
4. 核心功能模块实现:从列表检索到后台管理
4.1 舰船列表页与多条件筛选实现
列表页是整个系统曝光量最大的页面,我把这个模块的实现分为后端查询和前端渲染两层。
后端查询逻辑在 ShipController 中,接收 pageNum、pageSize、countryCode、shipTypeId、tier、keyword 这几个参数,分页查询返回结果。MyBatis-Plus 的 Page 对象配合 LambdaQueryWrapper 非常好用:
java复制@GetMapping("/ship/list")
public String shipList(@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "9") Integer pageSize,
@RequestParam(required = false) String countryCode,
@RequestParam(required = false) Integer shipTypeId,
@RequestParam(required = false) Integer tier,
@RequestParam(required = false) String keyword,
Model model) {
Page<ShipInfo> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<ShipInfo> wrapper = Wrappers.lambdaQuery();
wrapper.eq(ShipInfo::getStatus, 1);
if (StringUtils.isNotBlank(countryCode)) {
wrapper.eq(ShipInfo::getCountryCode, countryCode);
}
if (shipTypeId != null) {
wrapper.eq(ShipInfo::getShipTypeId, shipTypeId);
}
if (tier != null) {
wrapper.eq(ShipInfo::getTier, tier);
}
if (StringUtils.isNotBlank(keyword)) {
wrapper.and(w -> w.like(ShipInfo::getName, keyword)
.or().like(ShipInfo::getEnglishName, keyword));
}
wrapper.orderByAsc(ShipInfo::getTier).orderByAsc(ShipInfo::getId);
Page<ShipInfo> result = shipInfoService.page(page, wrapper);
// 填充舰船类型名称,避免前端重复查询
fillShipTypeName(result.getRecords());
model.addAttribute("page", result);
return "ship/list";
}
这里有一个细节值得注意:分页参数 pageSize 我默认设置为 9,而不是常用的 10 或者 20。原因是列表页的卡片设计是三列网格,每行显示 3 个舰船卡片,9 条正好是完整的三行,视觉效果最好。前端设计驱动后端参数选择,这个思路在真实项目中非常重要。
前端显示层用 Thymeleaf 渲染,国家、类型、等级三个筛选条件在页面上以标签按钮组的形式展示。用户点击某个筛选条件,页面通过表单 GET 请求重新加载列表,URL 保持可分享状态。这样用户把筛选条件发给朋友,对方打开也能看到同样的筛选结果。
舰船卡片上直接展示缩略图、名称、类型、等级、国家,点击进入详情页。图片没有真实资源的情况下,我用了占位图服务生成带舰船名称的图片,保证页面视觉效果完整。
4.2 详情页与浏览量统计
详情页是用户停留时间最长的页面,我的设计目标是信息密度高但不杂乱。页面布局分成三个区域:顶部是舰船基本信息卡,包含名称、国家、类型、等级、排水量等核心参数;中间是属性参数表格,包含主炮配置、鱼雷配置、航速等游戏向数据;底部是历史背景介绍区域。
浏览量统计是一个很小的功能,但实现方式有讲究。我最初的做法是在详情接口里直接执行 UPDATE 语句给 view_count 加 1,后来发现每次刷新页面都会触发一次写操作,在高并发场景下对数据库压力很大。优化后改成了 Redis 缓存计数,浏览量先累加到 Redis,定时任务定期把缓存值同步到数据库。
如果你没有引入 Redis,也可以做一个轻量优化:用 Map 在 JVM 内存中记录每个舰船的浏览量,定时写回数据库。但这种方案只适用于单实例部署,多个实例之间会出现计数不一致的问题。考虑到这个项目面向的是中小规模访问量,Redis 方案是合适的。
详情页的 Controller 实现,需要特别注意一个点:根据舰船ID查询时,要校验 status 状态,以免用户通过直链访问到下架舰船的详情:
java复制@GetMapping("/ship/{id}")
public String shipDetail(@PathVariable Long id, Model model) {
ShipInfo ship = shipInfoService.getById(id);
if (ship == null || ship.getStatus() != 1) {
return "error/404";
}
// 浏览量+1
incrementViewCount(id);
// 查询同类推荐舰船
List<ShipInfo> recommendList = shipInfoService.list(
Wrappers.lambdaQuery(ShipInfo.class)
.eq(ShipInfo::getShipTypeId, ship.getShipTypeId())
.eq(ShipInfo::getStatus, 1)
.ne(ShipInfo::getId, id)
.last("LIMIT 4")
);
model.addAttribute("ship", ship);
model.addAttribute("recommendList", recommendList);
return "ship/detail";
}
4.3 用户收藏功能:登录态与数据隔离的设计
用户收藏功能的核心是数据隔离。用户 A 的收藏列表绝对不能让用户 B 看到,更不能让游客操作收藏。这要求接口层必须严格校验当前登录用户。
我的实现方案是:登录成功后生成 JWT Token 存到前端 Cookie 中,后端通过拦截器从请求中取出 Token,解析出用户ID,放入 ThreadLocal。后续所有需要登录的接口直接从 ThreadLocal 获取用户信息。
收藏相关的两个接口如下:
java复制@PostMapping("/api/favorite/add")
@LoginRequired
public Result addFavorite(@RequestParam Long shipId) {
Long userId = UserContext.getUserId();
// 判断舰船是否存在
if (shipInfoService.getById(shipId) == null) {
return Result.error("舰船不存在");
}
// 判断是否已经收藏,防止重复收藏
long count = userFavoriteService.count(
Wrappers.lambdaQuery(UserFavorite.class)
.eq(UserFavorite::getUserId, userId)
.eq(UserFavorite::getShipId, shipId)
);
if (count > 0) {
return Result.error("你已经收藏过这艘舰船了");
}
UserFavorite favorite = new UserFavorite();
favorite.setUserId(userId);
favorite.setShipId(shipId);
userFavoriteService.save(favorite);
return Result.success();
}
这个接口里两次查询数据,一次校验舰船存在性,一次去重。有经验的同学可能会建议在表设计上直接加唯一索引 (user_id, ship_id),这样插入时数据库层面就能拦截重复,比应用层去重更可靠。这两个方案我最终都上了,数据库唯一索引作为最后防线,应用层校验提供友好的用户提示,双保险。
前端页面上的收藏按钮,根据当前登录状态和收藏状态显示不同样式。未登录时点击收藏按钮,前端弹窗提示“请先登录”;已收藏的舰船按钮变成已收藏的禁用态,点击可以取消收藏。这里用到了 Thymeleaf 的条件判断来区分收藏状态。
4.4 后台管理模块:权限控制与文件上传
后台管理模块是给管理员用的,核心功能是舰船信息的增删改查。我实现了一套基于拦截器的简单权限控制,登录用户的角色如果是 ADMIN,才能访问 /admin/** 路径下的请求。
拦截器里判断角色时,我直接从数据库查询用户角色,没有用 JWT 里的角色声明。因为考虑到有可能在系统运营过程中修改用户角色,每次请求查询数据库能保证角色变化即时生效,代价是多一次数据库查询。对于管理后台这种低频访问场景,这种代价完全可接受。
舰船新增和编辑页面是后台最复杂的页面,涉及图片上传和一个大的表单。图片上传我实现了本地磁盘存储方案,文件保存到指定目录,同时返回对应的访问 URL。前端表单里可以实时预览图片,体验不错。
上传接口的代码核心是接收 MultipartFile,检查文件类型和大小,然后保存并返回 URL:
java复制@PostMapping("/admin/api/upload")
@LoginRequired
@AdminRequired
public Result uploadFile(@RequestParam("file") MultipartFile file) {
if (file.isEmpty()) {
return Result.error("请选择文件");
}
String originalFilename = file.getOriginalFilename();
String ext = originalFilename.substring(originalFilename.lastIndexOf("."));
// 白名单校验文件类型
if (!Arrays.asList(".jpg", ".jpeg", ".png", ".gif").contains(ext.toLowerCase())) {
return Result.error("仅支持jpg/jpeg/png/gif格式图片");
}
// 限制文件大小 5MB
if (file.getSize() > 5 * 1024 * 1024) {
return Result.error("图片大小不能超过5MB");
}
String fileName = UUID.randomUUID().toString().replace("-", "") + ext;
try {
file.transferTo(new File(uploadDir + fileName));
} catch (IOException e) {
log.error("文件上传失败", e);
return Result.error("文件上传失败");
}
return Result.success("/upload/" + fileName);
}
文件的扩展名校验这里,我备注一下,不能只靠前端限制。用户完全可以绕过前端直接调用接口,所以后端必须有白名单校验。文件大小同样要在后端限制,否则可能被恶意上传大文件打爆磁盘。这些都是安全常识,但很新手在做项目时会忽略。
5. 项目打包部署与性能优化
5.1 使用 Docker Compose 部署 Spring Boot 项目
项目开发完要部署上线,这一步对很多初学者来说是一个坎,因为本地跑得好好的,到了服务器上问题就多了。我这次的部署方案是用 Docker Compose 把 Spring Boot 应用和 MySQL 数据库一起编排起来,一条命令搞定启动。
先写 Dockerfile,基于 JDK 8 镜像构建应用镜像:
dockerfile复制FROM openjdk:8-jre-alpine
WORKDIR /app
ADD target/wows-wiki-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
再写 docker-compose.yml,编排应用和数据库:
yaml复制version: '3'
services:
mysql:
image: mysql:5.7
container_name: wows-mysql
environment:
MYSQL_ROOT_PASSWORD: root123456
MYSQL_DATABASE: wows_wiki
TZ: Asia/Shanghai
ports:
- "3306:3306"
volumes:
- ./mysql-data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
restart: always
app:
build: .
container_name: wows-app
depends_on:
- mysql
ports:
- "8080:8080"
environment:
SPRING_PROFILES_ACTIVE: prod
restart: always
这里有一个重要细节:数据库容器的挂载卷。如果 MySQL 容器删除了重建,卷还能保留数据不丢失,否则数据库里的内容会随着容器一起消失,就白干了。
启动命令是 docker-compose up -d,首次启动会自动执行 init.sql 初始化数据库表结构和基础数据。整个部署过程行云流水。
从“手动部署”到“容器部署”的过程,我建议任何做 JavaWeb 项目的同学都走一遍。容器化的核心价值在于环境一致性:本地跑、测试环境跑、生产环境跑,用的都是同一个镜像,不会出现“本地没问题,服务器上就是跑不起来”的经典问题。
5.2 缓存优化与数据库索引调优
部署之后性能调优必不可少。对于一个中小规模的百科系统,性能瓶颈通常不在服务器而在数据库查询。
我的优化措施是一层一层加上的。
第一层,MyBatis-Plus 的分页查询本身就自带 SQL 优化,Page 对象会生成带 LIMIT 的分页 SQL,不需要手写。但要注意,如果表数据量很大,深分页(比如跳到第 10000 页)会产生性能问题,这通常靠限制最大页码来规避,我这个项目的数据量还不需要这么复杂的处理。
第二层,热点查询加缓存。舰船详情页是热点页面,同一个舰船会被大量用户反复查看。我在详情查询上加了一层基于 Caffeine 的本地缓存,缓存时间 10 分钟。这样同一艘船 10 分钟内的首次查询会打数据库,后续查询直接从内存返回:
java复制@Component
public class ShipDetailCache {
private final Cache<Long, ShipInfo> cache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
public ShipInfo getShipDetail(Long id) {
try {
return cache.get(id, shipInfoService::getById);
} catch (Exception e) {
return shipInfoService.getById(id);
}
}
}
Caffeine 是内存缓存,最大的问题是多实例部署时每个实例有自己的一份缓存,数据一致性无法保证。但结合百科系统的场景,舰船信息几乎不会频繁变更,10 分钟的过期时间完全够用,所以这个方案是合理的。
第三层,数据库索引优化。我在实际测试中发现,列表页按条件筛选时,如果没有合适的索引,MySQL 会执行全表扫描,耗时从毫秒级飙升到秒级。通过 EXPLAIN 命令分析了慢查询 SQL,发现 ship_type_id 和 tier 的联合索引缺失,补上之后查询性能提升非常明显:
sql复制ALTER TABLE ship_info ADD INDEX idx_type_tier (ship_type_id, tier);
ALTER TABLE ship_info ADD INDEX idx_country_type (country_code, ship_type_id);
性能优化没有银弹,最关键的工作是先找到慢查询,再针对性地加索引或者加缓存。不要一开始就优化,先让系统跑起来,用日志和监控找到瓶颈,然后逐个击破。
6. 踩坑实录:版本兼容、时区、跨域等问题排查
6.1 Spring Boot 版本太高引发的依赖兼容问题
这个坑我在前面已经提过,但这里再单独说一次,因为它太典型了。
我在项目初期使用了 Spring Boot 3.2.0,然后发现 MyBatis-Plus 的 mybatis-plus-boot-starter 3.5.3 版本在 Spring Boot 3.x 下会出现启动失败问题。报错信息是找不到 SqlSessionFactory 相关的 Bean,但实际上依赖都引入了。排查了很久才发现是版本不兼容。
解决方案有两个:要么把 Spring Boot 降级到 2.7.x,要么用适配 Spring Boot 3 的 mybatis-plus-spring-boot3-starter。我选择了前者,因为项目中还依赖了其他组件,整体生态停留在 Boot 2 时代最稳。
这里我建议大家做一个动作:在选型阶段,把整个技术栈所有组件的版本列出来,去官网或 Maven 仓库确认版本兼容矩阵。这比踩坑之后再查找资料省时间得多。
6.2 数据库时区与写入乱码问题
这个坑非常经典。项目启动后,向数据库插入数据,发现 createTime 字段比当前时间少了 8 个小时。原因很简单:MySQL 连接串里没有指定 serverTimezone,默认使用了服务器的 UTC 时区,而中国处于 UTC+8。
解决方式是在数据库连接串中明确指定:
java复制jdbc:mysql://localhost:3306/wows_wiki?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
乱码问题则是字符集不一致导致的。数据库表默认字符集如果是 latin1,插入中文就会变成问号。统一设置为 utf8mb4 是最稳妥的,因为 utf8mb4 是 utf8 的超集,不仅支持中文,还支持 emoji 字符:
sql复制CREATE DATABASE wows_wiki DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
这个坑发生在一个不太容易察觉的地方,就是数据库是 5.7 的默认字符集通常不是 utf8mb4,如果你建库建表的时候没有显式指定,就会出现中文乱码。
6.3 前后端分离时的 CORS 跨域问题
后台管理模块使用 Vue 3 + Element Plus 开发,开发阶段 Vue 的开发服务器跑在 5173 端口,Spring Boot 跑在 8080 端口,浏览器请求接口直接报了 CORS 错误。
解决方案不是在后端写一大堆跨域配置,而是用一个全局的 WebMvcConfigurer 配置类统一处理:
java复制@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}
注意 allowedOriginPatterns 和 allowCredentials(true) 必须搭配使用。如果你用 allowedOrigins("*") 同时设置 allowCredentials(true),某些版本的 Spring Boot 会直接报错,提示不允许这样配置。
另外,如果项目部署后是通过 Nginx 反向代理访问的,还可以直接在 Nginx 层配置 CORS,这样后端可以省掉这些配置。
6.4 单元测试中的 MockMvc 使用心得
项目最后阶段,我给核心接口补了单元测试。这个环节很多同学会跳过,但对于一个完整项目来说,测试是质量保障的重要组成部分。
用 MockMvc 测试 Controller 接口时,最需要注意的是模拟登录态。因为我的接口很多需要登录,测试时需要在请求头里带上 Token:
java复制@SpringBootTest
@AutoConfigureMockMvc
public class ShipControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
public void testShipList() throws Exception {
mockMvc.perform(MockMvcRequestBuilders.get("/ship/list")
.param("pageNum", "1")
.param("pageSize", "9"))
.andExpect(MockMvcResultMatchers.status().isOk())
.andExpect(MockMvcResultMatchers.view().name("ship/list"));
}
}
好的单元测试不是把全部代码都测一遍,而是优先测核心业务接口和数据校验逻辑。我统计大概覆盖了 60% 左右的接口,核心 CRUD 和权限校验逻辑都有了兜底。
6.5 Spring Boot 配置文件不加载的排查
还有一个常见问题是 application.yml 文件里的配置没有被加载。有一回我在 application.yml 里加了自定义的 upload-dir 配置,结果用 @Value("${upload-dir}") 注入时一直报错,提示找不到配置项。
排查思路是这样的:先检查配置文件的位置和文件名是否正确。Spring Boot 默认加载 classpath 下的 application.yml,如果你的文件放在 src/main/resources 下,注意修改后必须重新编译,否则旧的配置文件还在 target 目录里。
再检查配置项的缩进和格式是否正确。YAML 对缩进极其敏感,一个空格不对,整个配置就失效了。这是 YAML 格式的天坑,每次遇到配置文件不生效的问题,第一反应应该是检查格式。
一个可行的自查方法是用 @ConfigurationProperties(prefix = "upload") 来绑定配置类,而不是使用零散的 @Value。配置类可以统一管理属性,IDE 提示也更友好,还能避免拼写错误。
7. 项目扩展与二次开发建议
项目做到这里,基本功能已经完整了,但仍有几个方向值得扩展,我给后来者一些参考。
第一个方向是加入数据对比功能。玩家经常需要在几艘候选舰船之间做选择,目前系统只能逐个查看详情,不能横向比较。如果做一个对比功能,用户可以勾选多艘舰船,系统生成一个参数对比表格,这个功能对玩家来说非常实用。实现上也不难,前端用表格展示,后端接收多个 shipId,批量查询数据返回即可。
第二个方向是舰船科技树的可视化展示。《战舰世界》里的舰船之间有研发关系,同一国家的不同舰船构成科技树。目前数据库里没有存储这种层级关系,如果要支持,需要额外设计一个 ship_relation 表来存储前置舰船ID和后续舰船ID,前端可以借助开源的树形图库展示科技树。
第三个方向是评论系统。百科系统有了用户基础之后,评论区是增加活跃度的好手段。但评论系统涉及敏感词过滤、评论审核、楼中楼等复杂逻辑,需要谨慎规划。如果只是做一个简单的点赞和评论,可以先用数据库表加 MyBatis-Plus 快速实现,但要注意防刷。
第四个方向是引入全文搜索引擎。如果舰船数据和百科词条增加到几万条,MySQL 的 LIKE 查询就很难满足关键词搜索的性能需求了。可以引入 Elasticsearch 或者轻量级的 Lucene 做全文检索,但这会增加部署复杂度,需要权衡。
我自己做完这个项目最大的收获是:一个看似简单的“游戏百科系统”,完整走下来能覆盖 JavaWeb 开发链条上绝大多数核心知识点——版本选型、环境配置、数据库建模、MyBatis-Plus 使用、拦截器与权限控制、文件上传、单元测试、Docker 部署、性能调优。这远比单纯看教程有意义,也远比你想象中更能锻炼排查问题的能力。
最后分享一个实际操作中的小技巧,我在做这个项目时遇到问题,第一反应不是去搜索引擎搜解决方案,而是先看日志的完整堆栈信息。Spring Boot 的报错信息其实非常详细,很多时候它已经告诉了你问题在哪一行、哪个类、什么原因。先自己看日志,实在搞不定再去搜,这个习惯会帮你节省大量时间。希望这篇实战记录对你做类似的项目有所帮助。
