简介:面向Java后端与Web全栈初学者、毕业设计选题学生,这套基于Spring Boot的游戏创意工坊与推广平台源码包,完整实现了游戏创意分享、浏览、评论互动与开发者推广等核心业务,可帮助理解前后端分离开发与MySQL持久化设计。压缩包共690个文件,约29.31MB,包含156个Java后端源码、119个Vue前端页面、63个JS逻辑文件、20个XML配置及SQL数据库脚本等,另有bat快速安装/运行/构建脚本,便于本地复现项目。目录区分admin管理端、front用户端及mvnw等Maven Wrapper配置,资源组织结构清晰,可直接对照学习。目前已有54人学习浏览,适合在Spring Boot项目实战、毕设设计或简历项目中快速借鉴参考。
1. 从"创意工坊"到"推广平台"的 Spring Boot 落地
一个游戏创意工坊,看起来只是让用户上传创意、交流讨论,但放到毕设或真实业务场景里,它真正考验的是"内容管理"和"分发推广"两条链路能否在一个 Spring Boot 项目里稳定跑通。创意工坊解决的是 UGC 内容的产生、审核和展示问题,推广平台解决的是内容如何触达用户、如何用活动或公告带动活跃的问题,两者合在一起,构成了一个完整的社区型 Web 系统。
不带任何现成框架,用 Spring Boot + MySQL 从零实现时,大多数人会在角色权限设计、作品审核状态机、文件存储路径、以及列表页性能上反复踩坑。这套项目最值得拆解的地方也正在于此。本文会按照"先画架构和表、再写核心接口、再看数据库优化、最后处理部署"的顺序往下走,所有设计决策都基于可运行、可验证的最小闭环,能直接照着写代码,也能在实际业务里套用。
2. 基于 Spring Boot 的整体架构与核心表设计
2.1 为什么选择 Spring Boot 而不是 Servlet 原生或 SSM
游戏创意工坊这类项目有典型的分层需求:前端需要 RESTful 接口、后台需要权限拦截、业务层需要事务控制、持久层需要灵活的 SQL 拼装。用 Servlet 原生写法会把这些全部摊在 Controller 里,后期维护成本很高;SSM 本身没有问题,但配置繁琐,光是 Spring、Spring MVC、MyBatis 三个框架的 XML 配置就要花掉大量时间。Spring Boot 通过自动配置把这一步压缩到极简,一个@SpringBootApplication就能启动整个应用,尤其适合以毕设或中小型项目为粒度的交付场景。
选型时并不需要追求最新的 Spring Boot 3.x,如果环境是 JDK 8 + 主流云服务器,我一般会选 Spring Boot 2.7.x。2.7 版本依然兼容 javax 命名空间,资料多、依赖坑少,很多第三方 SDK 也仍然以 javax 为准。Spring Boot 3 强制 JDK 17 和 jakarta 命名空间,除非项目有明确的新技术指标,否则没有必要在这个量级的系统里给自己增加迁移成本。
2.2 数据表设计:围绕"作品 - 用户 - 活动"三条主线
创意工坊和推广平台的数据模型可以抽象成三条主线和若干辅助表。第一条主线是用户体系,第二条是作品/创意内容体系,第三条是推广体系。下面这张表结构直接对应数据库的建表脚本,字段命名和类型都适合 Spring Boot 项目直接使用。
| 表名 | 核心用途 | 关键字段 | 说明 |
|---|---|---|---|
user | 用户信息 | id, username, password, avatar, role | role区分管理员/普通用户/创作者 |
work | 创意作品 | id, user_id, title, cover, file_url, status, like_count | status控制审核状态 |
work_tag | 作品标签关联 | work_id, tag_id | 一对多标签,方便筛选 |
comment | 作品评论 | id, work_id, user_id, content, parent_id | 支持楼层回复 |
promotion | 推广活动 | id, title, cover, content, start_time, end_time | 活动时间区间内展示 |
banner | 首页轮播 | id, image_url, link_url, sort | 推广位配置 |
用户表和作品表之间是一对多关系,作品表和标签表是多对多关系,由于关联最频繁的是列表查询和筛选,我在设计时直接用中间表work_tag而不是在 work 表里存一个逗号拼接的 tag 字符串。逗号拼接虽然省一张表,但做"按标签筛选作品"时需要 in 操作无法走索引,数据量一上来就会出现明显的性能抖动。
2.2.1 作品表的状态机设计
作品表里最核心的字段是status,它不只是"上架/下架"两态。真实创意工坊场景里,作品从用户创建到最终展示,至少要经历待审核(0) -> 已通过(1) -> 已下架(2)这条链路,大部分项目还会加入草稿(3)和审核驳回(4)。建议用tinyint类型存储,配合 Java 枚举类做类型安全映射。
public enum WorkStatus { PENDING(0, "待审核"), APPROVED(1, "已上架"), OFF_SHELF(2, "已下架"), DRAFT(3, "草稿"), REJECTED(4, "已驳回"); private final int code; private final String desc; WorkStatus(int code, String desc) { this.code = code; this.desc = desc; } public int getCode() { return code; } public String getDesc() { return desc; } }这里用枚举而不是魔法数字,是因为审核操作在多处会被调用,比如管理员审核接口、用户重新提交草稿的接口、定时任务自动下架的接口。如果到处都是if (work.getStatus() == 1),改状态逻辑时需要全文搜索,极易漏改。
2.2.2 推广活动与 banner 的挂载方式
推广平台部分我见过很多设计,有的是直接在banner表里写死link_url指向某个前端路由,有的是用promotion表加position字段区分首页头部、首页中部、侧边栏等位置。推荐后者,因为推广平台的核心需求是"运营人员能自己配置",而不是每次改活动都要发版。position字段配合sort排序字段,查询时ORDER BY sort ASC即可拿到指定位置的展示列表,推广位的增删改就变成了纯数据库操作。
3. 核心接口实现:创意提交、审核流转与列表渲染
3.1 项目分包与通用响应结构
分包结构我一般按controller / service / mapper / entity / common五层组织,common里放统一响应类、异常处理器和拦截器。统一响应对象是前后端联调的基础,如果每个接口都返回不同结构,前端 Axios 拦截器写起来就很痛苦。
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static <T> Result<T> error(Integer code, String message) { Result<T> r = new Result<>(); r.setCode(code); r.setMessage(message); return r; } }这个类的核心价值在于把业务状态码和 HTTP 状态码解耦。现实情况是,很多接口即使业务失败也需要返回 HTTP 200(比如参数校验失败、会话过期),让前端通过code字段统一判断,否则前端的错误处理逻辑会被 HTTP 状态码和业务状态码搅在一起。实际项目中还会配合@RestControllerAdvice做全局异常捕获,把抛出的BusinessException转换成统一的Result.error()返回,避免堆栈信息直接暴露给前端。
3.2 用户认证:JWT 无状态登录与拦截器注册
游创平台不适合用 Session,因为作品浏览和下载接口会被大量访问,session 存在内存中会造成横向扩容困难,项目里我更常使用 JWT 方案。用户登录成功后,服务端签发 token,前端每次请求在Authorization头中带上该 token,后端通过拦截器解析并注入用户上下文。Spring Boot 中注册拦截器需要实现WebMvcConfigurer接口。
@Component public class AuthInterceptor implements HandlerInterceptor { @Autowired private JwtUtil jwtUtil; @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行预检请求和登录注册接口 if (request.getMethod().equals("OPTIONS")) { return true; } String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"未登录\"}"); return false; } token = token.substring(7); Integer userId = jwtUtil.parseToken(token); if (userId == null) { response.setStatus(401); response.getWriter().write("{\"code\":401,\"message\":\"token失效\"}"); return false; } UserContext.set(userId); return true; } @Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.remove(); } }拦截器的preHandle方法里有两个容易忽略的细节:一是 OPTIONS 预检请求必须在业务判断前放行,否则前端跨域配置会连环报错;二是 token 解析完成后要把用户信息放进ThreadLocal封装的UserContext,afterCompletion里再 remove,否则线程池复用时会出现用户串号。密码存储不建议用 MD5 明文,应当使用BCryptPasswordEncoder,把盐和哈希一起放在同一个字符串里,MyBatis 查询后直接比对,即使数据库泄露也不会立刻暴露明文密码。
3.3 创意作品发布的完整链路
创意上传这个接口是整个平台的核心,也是最容易出问题的模块。前端流程是用户填写标题、上传封面、选择标签、提交文件,服务端要做的事包括保存作品元数据、磁盘文件存储、标签关联写入。三步中任何一步失败,都需要通过事务回滚。第一步用@Transactional标注 service 方法即可,但需要注意自调用导致的失效问题——同一个类里methodA()调用methodB()时,methodB上的@Transactional不会生效,必须通过代理对象调用。
@Service public class WorkServiceImpl implements WorkService { @Override @Transactional(rollbackFor = Exception.class) public void publishWork(WorkPublishDTO dto, Integer userId) { Work work = new Work(); work.setUserId(userId); work.setTitle(dto.getTitle()); work.setCover(dto.getCoverUrl()); work.setFileUrl(dto.getFileUrl()); work.setStatus(WorkStatus.PENDING.getCode()); work.setLikeCount(0); workMapper.insert(work); workTagMapper.deleteByWorkId(work.getId()); if (!CollectionUtils.isEmpty(dto.getTagIds())) { workTagMapper.batchInsert(work.getId(), dto.getTagIds()); } // 发送审核通知,异步操作 messageService.sendAuditNotice(userId, work.getId()); } }rollbackFor = Exception.class是必须显式声明的。Spring 默认只对RuntimeException回滚,如果messageService.sendAuditNotice()里抛出一个受检异常,事务不会回滚,就会出现"作品已入库但通知没发出去"的中间状态。
文件上传推荐存储在服务器本地磁盘或云对象存储,不推荐塞进 MySQL 的 BLOB 字段。数据库存 BLOB 会导致行体积膨胀,SELECT列表页时如果误把该字段查出来,磁盘 I/O 和内存占用都会直线飙升。数据库里只保存file_url路径,上传接口通过 MultipartFile 直接写入指定目录:
# 项目根目录下创建 upload/ 目录,存放用户上传的图片和文件 mkdir -p /data/game-workshop/uploadpublic String uploadFile(MultipartFile file) { String originalFilename = file.getOriginalFilename(); String ext = originalFilename.substring(originalFilename.lastIndexOf(".")); String filename = UUID.randomUUID() + ext; String datePath = new SimpleDateFormat("yyyy/MM/dd").format(new Date()); File dir = new File(UPLOAD_DIR + datePath); if (!dir.exists()) { dir.mkdirs(); } File dest = new File(dir, filename); try { file.transferTo(dest); return "/upload/" + datePath + "/" + filename; } catch (IOException e) { throw new BusinessException("文件上传失败"); } }这里有三点需要强调。第一,文件名必须用 UUID 重命名,不能用用户上传的原名,内部文档测试时经常见到"接口说明.pdf"这种中文名覆盖问题,实际上是为了防止路径穿越和重名覆盖。第二,按日期分目录可以避免单个目录文件数过多导致文件系统 inode 耗尽。第三,transferTo方法在某些 servlet 容器下会先写临时文件再移动,如果目标目录和临时目录不在同一文件系统就会抛异常,必要时要改用FileOutputStream手动拷贝。
3.4 作品列表分页与标签筛选
列表接口是访问量最大的接口,推荐使用 MyBatis Plus 的分页插件PaginationInnerInterceptor。用 PageHelper 或者手写 LIMIT 也可以,但 MyBatis Plus 在 Spring Boot 项目里集成更顺滑,同时也保留了手写 SQL 的灵活性。分页查询时不能只传 pageNum 和 pageSize,排序字段和筛选条件要一起传入。
<select id="selectPageByCondition" resultType="com.example.entity.WorkVO"> SELECT w.id, w.title, w.cover, w.like_count, w.status, u.username AS author_name, GROUP_CONCAT(t.name SEPARATOR ',') AS tag_names FROM work w LEFT JOIN user u ON w.user_id = u.id LEFT JOIN work_tag wt ON w.id = wt.work_id LEFT JOIN tag t ON wt.tag_id = t.id <where> <if test="keyword != null and keyword != ''"> AND w.title LIKE CONCAT('%', #{keyword}, '%') </if> <if test="status != null"> AND w.status = #{status} </if> </where> GROUP BY w.id, u.username ORDER BY w.like_count DESC, w.id DESC </select>这个 SQL 用GROUP_CONCAT把同一作品的多标签合并成一个字符串,返回给前端直接展示,省去了一次循环查标签。ORDER BY w.like_count DESC, w.id DESC保证点赞量相同的情况下新作品排前面,分页时不会出现重复数据。这里的坑在于GROUP BY和ORDER BY的字段必须完全一致或满足函数依赖,MySQL 5.7 及以上默认开启ONLY_FULL_GROUP_BY,SELECT的字段必须包含在GROUP BY里,否则直接报错。
4. MySQL 优化:索引设计、全文检索与慢查询排查
4.1 索引设计:让每一条查询都有索引兜底
创意工坊平台的查询特点是一路带条件,一查连多表。核心索引有三条:work表的user_id索引解决"我的作品"查询,status索引解决管理员按状态筛选,双重条件则走联合索引(status, like_count)。如果status字段区分度高(只有 0/1/2/3/4),单列索引效果有限,联合索引的价值在于排序场景。
ALTER TABLE `work` ADD INDEX idx_user_id (`user_id`); ALTER TABLE `work` ADD INDEX idx_status_create_time (`status`, `create_time`); ALTER TABLE `comment` ADD INDEX idx_work_id (`work_id`);第二行索引(status, create_time)能同时支撑"按状态查列表按时间倒序"的场景,查询条件命中status后,create_time的排序可以直接从索引里取出,不需要 filesort。用EXPLAIN验证时,Extra列如果展示Using filesort,说明排序绕过了索引,需要调整索引顺序或增加覆盖字段。
4.2 慢查询日志与通用排查路径
系统上线后最常见的现象是列表页越翻越慢。排查路径是:先开慢查询日志找准慢 SQL,再用 EXPLAIN 看执行计划,最后按最左前缀原则补索引。开发机搭建时可以在 MySQL 配置文件里开启慢查询记录:
[mysqld] slow_query_log = 1 slow_query_log_file = /var/log/mysql/mysql-slow.log long_query_time = 1 log_queries_not_using_indexes = 1# 查看执行计划,重点看 type、rows 和 Extra 三列 EXPLAIN SELECT * FROM work WHERE status = 1 ORDER BY create_time DESC LIMIT 20;type列是 all 表示全表扫描,rows是预估扫描行数,网络搜索里常见的"索引失效"问题,多数出在查询条件中使用了函数或隐式类型转换。比如work表的status是 tinyint,Java 里传入字符串"1"时 MySQL 可以转换,但如果status传成"true"或 VARCHAR 串就很容易索引失效。排查索引问题时优先确认两个字段的类型是否匹配。
另一个高频坑是深分页问题。LIMIT 100000, 20会扫描前 100020 行然后丢弃前 100000 行,性能会随着页码增大急剧下降。如果后台管理界面确实需要翻到很后面,常见的替代方式是把分页改成基于游标,传入last_seen_id,SQL 条件变为WHERE id > #{lastSeenId} ORDER BY id ASC LIMIT 20。缺点是不能跳页,但绝大多数后台管理操作是连续翻页,这个取舍是划算的。
4.3 全文检索:用倒排思路替代 LIKE
作品标题和简介的关键字搜索场景下,LIKE '%keyword%'无法利用索引,表里两三万行时还能扛,数据量过十万就会拖慢主库。如果只用一个 MySQL 实例,做法是建一个work_fulltext表单独存搜索内容,使用 MySQL 的内置 FULLTEXT 索引:
ALTER TABLE `work` ADD FULLTEXT INDEX ft_title_desc (`title`, `description`); SELECT * FROM work WHERE MATCH(title, description) AGAINST('像素 冒险' IN NATURAL LANGUAGE MODE) LIMIT 20;AGAINST里可以写多个关键词,MySQL 会自动分词并匹配。这样搜索流量和正常列表流量在查询上做到了物理隔离,全文索引的更新由应用层在作品通过审核后同步写入,一致性通过事务控制。如果数据量到了百万级,再考虑迁移到 Elasticsearch,但那是另一个量级的话题了。
5. 部署上线与配置踩坑:从源码到可访问站点的最短路径
5.1 本地环境准备与项目初始化
拿到源码包后,先做环境对齐,再做配置修改。推荐的 JDK 版本是 1.8,Maven 版本 3.6+,MySQL 5.7 或 8.0 均可。先在 MySQL 里执行项目附带的 sql 文件,完成建库和数据初始化:
mysql -uroot -p123456 < /path/to/init.sql-- 使用数据库 USE game_workshop; -- 查看已导入的表数量 SHOW TABLES;执行完之后确认表数据无误,再修改 Spring Boot 项目里的application.yml。这个文件是所有配置的集中地,数据库连接、文件上传路径、JWT 密钥都在这里。项目里最常见的启动失败原因就是数据库地址、账号密码对不上,其次是时区配置。
spring: datasource: url: jdbc:mysql://localhost:3306/game_workshop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver servlet: multipart: max-file-size: 50MB max-request-size: 100MB mvc: static-path-pattern: /upload/** mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: log-impl: org.apache.ibatis.logging.stdout.StdOutImpldriver-class-name在 MySQL 5.7 下可以写com.mysql.jdbc.Driver,但 MySQL 8.0 必须换成com.mysql.cj.jdbc.Driver,且 url 里必须带serverTimezone参数,否则连接会直接报Connection refused或时间转换异常。上传目录的访问依赖 static-path-pattern 映射到本地磁盘的upload目录,否则前端的图片 URL 会 404。
5.2 打包命令与 jar 包启动
本地开发跑通后,需要构建可部署的 jar 包。Spring Boot 内嵌 Tomcat,不需要单独装 Tomcat,一条命令就能跑起来:
# 跳过测试,打包 mvn clean package -DskipTests # 启动 jar 包,指定 JVM 参数和激活配置 java -Xms256m -Xmx512m -jar game-workshop-0.0.1-SNAPSHOT.jar --spring.profiles.active=prod-Xms和-Xmx分别指定堆内存初始值和最大值,如果是 1 核 2G 的云服务器,建议设为-Xms512m -Xmx512m即可,加大-Xmx反而会挤压操作系统文件缓存的可用内存。--spring.profiles.active=prod用于激活不同环境的配置文件,项目里应当准备application-dev.yml和application-prod.yml两份,prod 里的数据库密码通过环境变量注入,不写死在 yml 文件里。
启动之后先看日志。Spring Boot 的日志会直接打到控制台,用tail盯住输出:
java -jar game-workshop.jar > app.log 2>&1 & tail -f app.log看到Started Application in xx seconds即启动成功。如果端口被占用,通过lsof -i:8080确认占用进程,或在启动参数中追加--server.port=8081指定其他端口。
5.3 项目内文件上传路径与前端联动
jar 包方式和 IDE 方式运行有一个显著区别:new File("upload/")的相对路径基准不同。在 IDEA 里跑,基准是项目根目录;用java -jar部署,基准是 jar 包所在目录。因此生产环境必须把上传路径写成绝对路径:
app: upload-dir: /data/game-workshop/upload对应的代码里用@Value("${app.upload-dir}")注入,替换掉写死的相对路径。前端图片显示时,/upload/2024/03/07/xxx.png这个 URL 经过 Nginx 反向代理后指向 Spring Boot,如果部署了 Nginx,也可以把/upload/直接交给 Nginx 静态文件服务,不需要经过 Java 层转发,减少一层内存拷贝:
location /upload/ { alias /data/game-workshop/upload/; }这样配置之后,静态资源请求直接由 Nginx 返回,只有接口请求才转发到后端,后端 Tomcat 的连接数和线程压力会明显下降。
5.4 用 curl 验证核心接口
部署完成后不用急着打开前端页面,先用 curl 按接口文档冒烟一遍:
# 1. 用户登录获取 token curl -X POST http://localhost:8080/api/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"admin123"}' # 2. 携带 token 获取作品列表 curl -X GET "http://localhost:8080/api/work/list?page=1&size=10" \ -H "Authorization: Bearer <替换为实际token>" # 3. 模拟上传文件(multipart 形式) curl -X POST http://localhost:8080/api/file/upload \ -H "Authorization: Bearer <token>" \ -F "file=@./test.png"这三个接口分别覆盖了认证、业务查询、文件上传三条核心链路。如果第 2 步返回 401,检查 token 是否过期或Authorization头拼写;如果第 3 步返回 500,先去/data/game-workshop/upload看目录是否存在、是否有写权限。文件目录建议使用chown给启动 jar 包的系统用户授权,不要图省事用 root 跑 Java 进程。以上逐条通过,项目才算真正落地。
本文还有配套的精品资源,点击获取