基于 Spring Boot + Vue3 的棋牌竞技赛事管理系统设计与实现

温馨提示:本人主页置顶文章(点我)开头有 CSDN 平台官方提供的学长联系名片!

一、项目背景与意义

棋牌类竞技项目(如象棋、围棋、斗地主、麻将等)在国内拥有庞大的参与人群,但多数线下赛事仍依赖人工报名、纸质编排和手工记分,存在报名信息分散、赛程编排效率低、成绩统计易出错、赛事数据难以沉淀等问题。随着线上棋牌竞技的规范化发展,构建一套覆盖「赛事创建—选手报名—赛程编排—对局记录—积分排名—数据统计」全流程的管理系统,具有明确的实用价值。

本项目采用前后端分离架构,后端基于 Spring Boot 构建 RESTful 服务,前端使用 Vue3 + Element Plus 实现管理端与选手端界面,数据库选用 MySQL,缓存与排行榜使用 Redis,力求在保证系统可扩展性的同时,降低中小型赛事组织方的使用门槛。

二、系统需求分析

2.1 角色划分

  • 系统管理员:负责用户管理、赛事审核、系统参数配置、数据统计与日志查看。
  • 赛事组织者:创建赛事、设置赛制与规则、编排赛程、录入对局结果、发布公告。
  • 参赛选手:浏览赛事、在线报名、查看个人赛程与对局安排、查询积分与排名。
  • 裁判(可选):对争议对局进行裁定、复核成绩。

2.2 核心功能需求

模块主要功能
用户与权限注册登录、JWT 鉴权、角色权限控制、个人信息维护
赛事管理赛事创建与编辑、赛制配置(单循环、双败淘汰、瑞士轮)、报名审核、赛事状态流转
报名管理在线报名、资格校验、报名名单导出、退赛处理
赛程编排自动生成对阵表、轮次安排、场地与时间分配、手动调整
对局与成绩对局记录录入、胜负判定、积分计算、成绩复核
排名统计实时积分榜、胜率统计、历史战绩、赛事数据看板
公告与通知赛事公告发布、站内消息提醒

2.3 非功能性需求

  • 性能:排行榜查询响应时间控制在 200ms 以内,支持千人级赛事并发报名。
  • 安全:接口鉴权、参数校验、防 SQL 注入与 XSS、敏感操作留痕。
  • 可维护性:分层清晰、统一异常处理、统一返回结构、接口文档自动化。

三、系统总体架构设计

系统整体采用前后端分离的三层架构,前端通过 Axios 调用后端 REST 接口,后端按 Controller—Service—Mapper 分层组织,数据层使用 MyBatis-Plus 操作 MySQL,Redis 承担缓存与排行榜职责。

flowchart TD A[Vue3 前端应用] -->|HTTP / JSON| B[Nginx 反向代理] B --> C[Spring Boot 应用] C --> D[Controller 层] D --> E[Service 业务层] E --> F[Mapper 数据访问层] F --> G[(MySQL 业务库)] E --> H[(Redis 缓存与排行榜)] C --> I[文件存储 赛事海报与证书] C --> J[定时任务 赛程自动推进]

3.1 技术选型

层次技术选型说明
前端框架Vue3 + Vite组合式 API,构建速度快
UI 组件Element Plus后台管理常用组件库
状态管理Pinia管理登录态与全局配置
路由Vue Router 4动态路由与权限守卫
后端框架Spring Boot 3.x自动装配、内嵌容器
持久层MyBatis-Plus简化 CRUD 与分页
数据库MySQL 8.0业务数据存储
缓存Redis 7排行榜 ZSet、Token 黑名单
鉴权Spring Security + JWT无状态认证与权限控制
接口文档SpringDoc OpenAPI自动生成 Swagger 文档

四、数据库设计

数据库围绕「用户—赛事—报名—对局—积分」主线设计,核心表结构如下。

表名说明关键字段
sys_user用户表id、username、password、nickname、role、status
tournament赛事表id、title、game_type、format、start_time、end_time、status、organizer_id
registration报名表id、tournament_id、user_id、seed_no、audit_status、create_time
match_round轮次表id、tournament_id、round_no、start_time
match_game对局表id、round_id、player_a_id、player_b_id、score_a、score_b、result、status
ranking积分表id、tournament_id、user_id、points、wins、losses、draws
notice公告表id、tournament_id、title、content、publish_time

其中match_game与ranking是成绩统计的核心,对局结果确认后通过事务同步更新积分表,避免出现成绩与排名不一致的情况。

五、后端核心实现

5.1 项目结构

tournament-server ├── src/main/java/com/example/tournament │ ├── common 统一返回、异常、常量 │ ├── config Security、Redis、MyBatis 配置 │ ├── controller 接口层 │ ├── service 业务层 │ ├── mapper 数据访问层 │ ├── entity 实体类 │ └── dto 请求与响应对象 └── src/main/resources ├── application.yml └── mapper 复杂 SQL 映射

5.2 统一返回与全局异常处理

@Data public class Result<T> { private Integer code; private String message; private T data; public static &lt;T&gt; Result&lt;T&gt; success(T data) { Result&lt;T&gt; r = new Result&lt;&gt;(); r.setCode(200); r.setMessage("success"); r.setData(data); return r; } public static &lt;T&gt; Result&lt;T&gt; fail(Integer code, String message) { Result&lt;T&gt; r = new Result&lt;&gt;(); r.setCode(code); r.setMessage(message); return r; } }
@RestControllerAdvice public class GlobalExceptionHandler { @ExceptionHandler(BusinessException.class) public Result<Void> handleBusiness(BusinessException e) { return Result.fail(e.getCode(), e.getMessage()); } @ExceptionHandler(MethodArgumentNotValidException.class) public Result<Void> handleValid(MethodArgumentNotValidException e) { String msg = e.getBindingResult().getFieldError().getDefaultMessage(); return Result.fail(400, msg); } @ExceptionHandler(Exception.class) public Result<Void> handleOther(Exception e) { return Result.fail(500, "系统繁忙,请稍后重试"); } }

5.3 赛事报名接口

@RestController @RequestMapping("/api/registration") @RequiredArgsConstructor public class RegistrationController { private final RegistrationService registrationService; @PostMapping public Result&lt;Long&gt; register(@RequestBody @Valid RegistrationDTO dto, @AuthenticationPrincipal LoginUser user) { return Result.success(registrationService.register(dto, user.getId())); } @GetMapping("/page") public Result&lt;Page&lt;RegistrationVO&gt;&gt; page(RegistrationQuery query) { return Result.success(registrationService.page(query)); } }
@Service @RequiredArgsConstructor public class RegistrationServiceImpl implements RegistrationService { private final RegistrationMapper registrationMapper; private final TournamentMapper tournamentMapper; @Override @Transactional(rollbackFor = Exception.class) public Long register(RegistrationDTO dto, Long userId) { Tournament t = tournamentMapper.selectById(dto.getTournamentId()); if (t == null || !"SIGNING".equals(t.getStatus())) { throw new BusinessException(400, "赛事不存在或不在报名阶段"); } Long count = registrationMapper.selectCount(new LambdaQueryWrapper<Registration>() .eq(Registration::getTournamentId, dto.getTournamentId()) .eq(Registration::getUserId, userId)); if (count > 0) { throw new BusinessException(400, "您已报名该赛事"); } Registration r = new Registration(); r.setTournamentId(dto.getTournamentId()); r.setUserId(userId); r.setAuditStatus("PENDING"); r.setCreateTime(LocalDateTime.now()); registrationMapper.insert(r); return r.getId(); } }

5.4 基于 Redis 的实时排行榜

积分榜使用 Redis 有序集合存储,成员为用户 ID,分值为积分,查询时按分值倒序取前 N 名,兼顾实时性与性能。

@Service @RequiredArgsConstructor public class RankingService { private final StringRedisTemplate redisTemplate; private String key(Long tournamentId) { return "tournament:rank:" + tournamentId; } public void updateScore(Long tournamentId, Long userId, double points) { redisTemplate.opsForZSet().add(key(tournamentId), String.valueOf(userId), points); } public List&lt;RankItem&gt; topN(Long tournamentId, int n) { Set&lt;ZSetOperations.TypedTuple&lt;String&gt;&gt; tuples = redisTemplate.opsForZSet().reverseRangeWithScores(key(tournamentId), 0, n - 1); List&lt;RankItem&gt; list = new ArrayList&lt;&gt;(); if (tuples == null) { return list; } int rank = 1; for (ZSetOperations.TypedTuple&lt;String&gt; t : tuples) { list.add(new RankItem(rank++, Long.valueOf(t.getValue()), t.getScore())); } return list; } }

5.5 赛程自动编排思路

以单循环赛制为例,采用轮转法生成对阵:将参赛选手按种子序号排列,固定首位,其余选手循环轮转,每轮两两配对,轮空位由虚拟选手占位。核心逻辑如下。

public List<List<int[]>> generateRoundRobin(List<Long> players) { List<Long> list = new ArrayList<>(players); if (list.size() % 2 != 0) { list.add(-1L); } int n = list.size(); int rounds = n - 1; List<List<int[]>> result = new ArrayList<>(); for (int r = 0; r < rounds; r++) { List<int[]> pairs = new ArrayList<>(); for (int i = 0; i < n / 2; i++) { Long a = list.get(i); Long b = list.get(n - 1 - i); if (a != -1L && b != -1L) { pairs.add(new int[]{a.intValue(), b.intValue()}); } } result.add(pairs); list.add(1, list.remove(n - 1)); } return result; }

六、前端核心实现

6.1 工程结构

tournament-web ├── src │ ├── api 接口封装 │ ├── assets 静态资源 │ ├── components 通用组件 │ ├── layout 布局与菜单 │ ├── router 路由与守卫 │ ├── stores Pinia 状态 │ ├── utils 请求封装与工具 │ └── views 页面视图

6.2 请求封装与统一拦截

import axios from 'axios' import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const store = useUserStore() if (store.token) { config.headers.Authorization = Bearer ${store.token} } return config }) request.interceptors.response.use( res => { const { code, message, data } = res.data if (code === 200) return data ElMessage.error(message || '请求失败') return Promise.reject(new Error(message)) }, err => { if (err.response?.status === 401) { useUserStore().logout() } ElMessage.error(err.message) return Promise.reject(err) } ) export default request

6.3 赛事列表页

<template> <el-card> <el-table :data="list" v-loading="loading"> <el-table-column prop="title" label="赛事名称" /> <el-table-column prop="gameType" label="项目" width="120" /> <el-table-column prop="format" label="赛制" width="140" /> <el-table-column prop="startTime" label="开始时间" width="180" /> <el-table-column label="状态" width="120"> <template #default="{ row }"> <el-tag :type="statusType(row.status)">{{ statusText(row.status) }}</el-tag> </template> </el-table-column> <el-table-column label="操作" width="160"> <template #default="{ row }"> <el-button type="primary" link @click="goDetail(row.id)">详情</el-button> <el-button type="success" link :disabled="row.status !== 'SIGNING'" @click="signUp(row)">报名</el-button> </template> </el-table-column> </el-table> <el-pagination v-model:current-page="query.pageNum" v-model:page-size="query.pageSize" :total="total" layout="total, prev, pager, next" @current-change="loadList" /> </el-card> </template> <script setup> import { ref, reactive, onMounted } from 'vue' import { ElMessage } from 'element-plus' import { pageTournament, signUpTournament } from '@/api/tournament' const list = ref([]) const total = ref(0) const loading = ref(false) const query = reactive({ pageNum: 1, pageSize: 10 }) const loadList = async () => { loading.value = true try { const data = await pageTournament(query) list.value = data.records total.value = data.total } finally { loading.value = false } } const signUp = async row => { await signUpTournament(row.id) ElMessage.success('报名成功,等待审核') } onMounted(loadList) </script>

6.4 路由权限守卫

router.beforeEach((to, from, next) => { const store = useUserStore() if (to.meta.public) return next() if (!store.token) return next({ path: '/login', query: { redirect: to.fullPath } }) if (to.meta.roles && !to.meta.roles.includes(store.role)) { return next({ path: '/403' }) } next() })

七、关键业务难点与解决方案

  • 并发报名重复提交:在registration表上建立(tournament_id, user_id)唯一索引,配合前端按钮防抖与后端事务校验,双重保证不重复报名。
  • 成绩与排名一致性:对局结果确认、积分更新、Redis 排行榜刷新放在同一业务事务中,Redis 更新失败时通过定时任务补偿重建。
  • 赛程调整的连锁影响:对局表引入version字段做乐观锁,调整赛程时校验版本号,避免多人同时编排导致数据覆盖。
  • 排行榜冷启动:服务启动或赛事首次访问时,从 MySQL 积分表批量回填 Redis,保证榜单数据完整。

八、系统测试与部署

8.1 测试要点

  • 接口层使用 Postman 与 SpringDoc 文档进行功能验证。
  • 业务层使用 JUnit 5 + Mockito 覆盖报名校验、积分计算、赛程编排等核心逻辑。
  • 并发场景使用 JMeter 模拟 500 人同时报名,验证唯一索引与事务的正确性。

8.2 部署方式

# 后端打包 mvn clean package -DskipTests java -jar tournament-server.jar --spring.profiles.active=prod 前端构建 npm install npm run build 将 dist 目录交由 Nginx 托管,并反向代理 /api 到后端服务
server { listen 80; server_name tournament.example.com; location / { root /usr/share/nginx/html; try_files $uri $uri/ /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; } }

九、总结与展望

本文围绕棋牌竞技赛事管理场景,完成了从需求分析、架构设计、数据库建模到前后端核心功能实现的完整梳理。系统以 Spring Boot 提供稳定的业务服务,以 Vue3 构建清晰易用的操作界面,并通过 Redis 排行榜与自动赛程编排提升了赛事组织的效率。

后续可从三个方向继续演进:一是引入 WebSocket 实现赛程与成绩的实时推送;二是接入 Elo 等更精细的等级分算法,提升排名的科学性;三是增加数据可视化大屏,为赛事运营提供更直观的决策依据。