基于 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 <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> fail(Integer code, String message) { Result<T> r = new Result<>(); 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<Long> register(@RequestBody @Valid RegistrationDTO dto, @AuthenticationPrincipal LoginUser user) { return Result.success(registrationService.register(dto, user.getId())); } @GetMapping("/page") public Result<Page<RegistrationVO>> 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<RankItem> topN(Long tournamentId, int n) { Set<ZSetOperations.TypedTuple<String>> tuples = redisTemplate.opsForZSet().reverseRangeWithScores(key(tournamentId), 0, n - 1); List<RankItem> list = new ArrayList<>(); if (tuples == null) { return list; } int rank = 1; for (ZSetOperations.TypedTuple<String> 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 request6.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 等更精细的等级分算法,提升排名的科学性;三是增加数据可视化大屏,为赛事运营提供更直观的决策依据。