简介:基于SSM(Spring+SpringMVC+MyBatis)和MySQL搭建的乒乓球比赛管理系统源码,面向Java Web初学者、毕业设计或课程设计人群。系统覆盖赛事创建、选手报名、赛程编排、比分记录、成绩统计与权限控制等完整业务链路,体现依赖注入、AOP事务处理、MVC分层和持久层映射等核心开发思路,可直接导入IDE运行学习。压缩包共719个文件,大小5.64MB,包含170个Java源码、139个JS脚本、107个GIF动图、96个PNG图片、85个CSS样式、32个HTML页面及XML、SQL等配置与数据库初始化文件,前端界面、后端逻辑和数据库脚本一应俱全,便于对照理解。已有200人学习下载,配置有清晰的目录结构和Maven/Git等工程管理痕迹,适合用来复盘SSM整合流程、练习MySQL表设计及实现一个可运行的管理系统。
1. 基于 SSM 和 MySQL 的乒乓球比赛管理系统:架构定位与落地路径
单位组织乒乓球比赛,裁判和工作人员要处理报名名单、分组抽签、赛程排布、比分登记、晋级关系,Excel 管到 32 人以上基本失控。标题中的 SSM 指 Spring、SpringMVC、MyBatis 的组合,配上 MySQL 做持久化,这是 Java Web 管理系统最经典的落地方式之一。这套组合分层明确:Spring 管理对象和事务,SpringMVC 定义接口路由,MyBatis 保留 SQL 控制权,MySQL 负责存储业务数据。SSM 需要手动维护 XML 配置,过程能暴露框架协作原理,因此仍被大量毕业设计和内部管理系统采用。
这篇文章不依赖所谓源码包,而是直接带你从数据库表结构开始,完成后端接口、前端联调、打包部署和线上排错。可以直接照抄建表 SQL、Mapper XML、Service 方法和部署命令,适合正在做毕业设计的学生、需要快速交付赛事系统的开发者,以及复习 SSM 细节的求职者。
2. 数据库设计:乒乓球比赛管理系统的 MySQL 表结构与核心参数
2.1 先理清业务实体:赛事、报名、赛程、比分如何关联
一个可用的乒乓球比赛管理系统,业务上至少要覆盖四个环节:
- 赛事管理:创建“2025 春季职工乒乓球赛”这样的赛事,记录比赛类型、开始结束时间、当前状态。
- 报名管理:运动员报名参赛,一个赛事下有多名运动员,同一运动员也可报多个赛事,所以需要一张报名关联表。
- 赛程管理:赛程是系统的核心,每场比赛归属于某个赛事,记录对阵双方、轮次、球台号、比赛时间和比赛状态。
- 比分管理:乒乓球比赛按局记录,比赛可能打五局三胜,所以比分需要记录局号和双方得分,最终汇总出获胜方。
从实体关系看,运动员与赛事是多对多,通过 entry 表解除;赛程与赛事是多对一,比分与赛程是一对多。大部分管理系统都是这种套路:主体表、关联表、明细表。设计时最需要避免的是把比分字段直接堆在赛程表里,比如加一行 score_a、score_b 只存总比分。这样做看起来省事,但一旦要细分“每局的小分”,就只好用逗号拼接字符串,后续统计净胜局和胜负关系会非常痛苦。所以哪怕是一个小型系统,也要把比分拆成独立明细表。这里有一个经验:凡是将来要做“聚合统计”的字段,尽量拆成行而不是用分隔符合并进一个字段。MySQL 对行数其实很宽容,千级赛事打成几十万条比分记录完全没压力。
权限方面,通常需要一个 sys_user 表存登录账号和角色。运动员信息可以单独建 player 表,也可以用 user_id 和 sys_user 关联。角色设计成管理员、裁判、普通用户三个档位就够用:管理员能创建赛事和修改所有数据,裁判负责录比分,普通用户只能查看和报名。别做太细的权限矩阵,那会让登录校验代码膨胀,对这类内部系统没有实际收益。
2.2 MySQL 建表 DDL 与字段选择说明
下面这套 DDL 可以直接在 MySQL 8.0 上执行,字符集统一用 utf8mb4。选择 utf8mb4 而不是 utf8 的原因后面会单独讲。先看建库和建表语句。
CREATE DATABASE IF NOT EXISTS pingpong DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE pingpong; -- 1. 系统用户表:管理登录账号、密码、角色 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) NOT NULL UNIQUE, password VARCHAR(128) NOT NULL, real_name VARCHAR(32) DEFAULT '', role TINYINT NOT NULL DEFAULT 3 COMMENT '1管理员 2裁判 3普通用户', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='系统用户'; -- 2. 运动员表:非登录用户也能成为运动员,故独立建表 CREATE TABLE player ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(32) NOT NULL, gender TINYINT NOT NULL DEFAULT 1 COMMENT '1男 2女', phone VARCHAR(20) DEFAULT '', user_id INT DEFAULT NULL COMMENT '可关联登录账号', KEY idx_user (user_id) ) ENGINE=InnoDB COMMENT='运动员'; -- 3. 赛事表 CREATE TABLE tournament ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(64) NOT NULL, type TINYINT NOT NULL DEFAULT 1 COMMENT '1单打 2双打 3团体', start_date DATE DEFAULT NULL, end_date DATE DEFAULT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0报名中 1进行中 2已结束', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='赛事'; -- 4. 报名表:运动员与赛事的多对多关联 CREATE TABLE entry ( id INT PRIMARY KEY AUTO_INCREMENT, tournament_id INT NOT NULL, player_id INT NOT NULL, seed TINYINT NOT NULL DEFAULT 0 COMMENT '是否种子选手', create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_entry_tournament_player (tournament_id, player_id), KEY idx_entry_player (player_id) ) ENGINE=InnoDB COMMENT='报名记录'; -- 5. 赛程表:一场比赛一行,双打时 player_a/player_b 存组合编号 CREATE TABLE match_schedule ( id INT PRIMARY KEY AUTO_INCREMENT, tournament_id INT NOT NULL, round_no INT NOT NULL DEFAULT 1, player_a INT NOT NULL, player_b INT NOT NULL, match_time DATETIME DEFAULT NULL, table_no INT DEFAULT 0 COMMENT '球台号', status TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', winner INT DEFAULT NULL COMMENT '胜者player或组合id', KEY idx_match_tournament (tournament_id), KEY idx_match_status (status) ) ENGINE=InnoDB COMMENT='赛程'; -- 6. 比分表:一局一条记录,比赛结束前允许反复修改 CREATE TABLE match_score ( id INT PRIMARY KEY AUTO_INCREMENT, match_id INT NOT NULL, set_no TINYINT NOT NULL COMMENT '第几局', score_a TINYINT NOT NULL DEFAULT 0, score_b TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_score_match_set (match_id, set_no) ) ENGINE=InnoDB COMMENT='每局比分';对字段选择有几个要注意的地方:第一,所有业务表都用 InnoDB 且不设外键约束,关联只通过索引在业务层保证,这样导入数据、删改数据时不会被外键卡住,是现在大多数 MySQL 业务表的设计习惯;但唯一键不能省,比如 entry 表的 (tournament_id, player_id) 唯一键,可以拦住同一运动员重复报名。第二,status 这类状态字段用 TINYINT 而不是字符枚举,因为后续加状态不需要改字段类型,Java 侧用常量或枚举类解释数字即可。第三,双打比赛的 player_a/player_b 直接存组合编号,组合编号可以用“运动员 id 拼接”生成,比如 12_34,这样赛程表不需要为双打单独建组合表。
时间字段这里只使用了 DATETIME,没有用 TIMESTAMP。原因很简单:TIMESTAMP 有 2038 年上限,DATETIME 的范围是 1000 到 9999 年,对于记录比赛时间,DATETIME 更稳妥,也避免不同时区下的显示偏移。MySQL 8.0 里 DATETIME 默认支持小数秒,但业务上用不到。注意建表时没写外键,如果将来要做数据清理,直接 TRUNCATE 子表再清主表即可,省去外键检查的麻烦。
2.3 MySQL 连接参数、字符集与连接池配置
建表完成之后,先别急着写代码,把 MySQL 连接层参数定下来能避开很多坑。第一个坑是字符集。从 MySQL 5.7 开始 utf8mb4 才是完整 UTF-8,utf8 只是 utf8mb3,无法存放四字节 emoji 字符。虽然乒乓球报名信息里很少出现 emoji,但运动员备注、日志字段里一旦出现中文生僻字加特殊符号,utf8 就可能报“Incorrect string value”。所以建库、建表、JDBC URL 三处都要统一 utf8mb4。
第二个坑是时区。MySQL 8.0 默认时区是 UTC,而大多数服务器和本地机器是东八区。如果 JDBC URL 不指定 serverTimezone,或者指定错了,查询时间字段会刚好慢 8 小时,甚至抛异常。下列配置是项目里最常用的一组:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| jdbcUrl 字符集 | useUnicode=true&characterEncoding=utf8mb4 | 与建库字符集保持一致 |
| jdbcUrl 时区 | serverTimezone=Asia/Shanghai | 消除 8 小时时差 |
| jdbcUrl 加密 | useSSL=false | 内网环境省掉证书告警 |
| Druid 初始连接数 | 5 | 冷启动后连接池预热 |
| Druid 最大连接数 | 20 | 内部系统并发有限,20 足够 |
这里用 Alibaba Druid 连接池来举例,因为它自带监控页面,在管理系统里查看 SQL 执行情况很方便。下面给出 spring-context.xml 里数据源和事务管理器的核心配置,后面会在这个基础上装配 MyBatis。
<context:property-placeholder location="classpath:jdbc.properties"/> <bean id="dataSource" class="com.alibaba.druid.pool.DruidDataSource" init-method="init" destroy-method="close"> <property name="driverClassName" value="com.mysql.cj.jdbc.Driver"/> <property name="url" value="jdbc:mysql://localhost:3306/pingpong?useUnicode=true&characterEncoding=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false"/> <property name="username" value="${jdbc.username}"/> <property name="password" value="${jdbc.password}"/> <property name="initialSize" value="5"/> <property name="maxActive" value="20"/> <property name="minIdle" value="5"/> </bean> <bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean"> <property name="dataSource" ref="dataSource"/> <property name="mapperLocations" value="classpath:mapper/*.xml"/> </bean> <bean class="org.mybatis.spring.mapper.MapperScannerConfigurer"> <property name="basePackage" value="com.pingpong.dao"/> </bean> <bean id="transactionManager" class="org.springframework.jdbc.datasource.DataSourceTransactionManager"> <property name="dataSource" ref="dataSource"/> </bean> <tx:annotation-driven transaction-manager="transactionManager"/>这个配置里的关键参数前面表格已经说过。补充一点:XML 中&符号必须写成&,JDBC URL 里的多个参数用&连接,写进 properties 文件时不用转义,写进 XML 时必须转义。这个细节常常让人卡上半小时。MapperScannerConfigurer 的 basePackage 要填写 Mapper 接口所在的包,它会把该包下所有接口动态代理成 MyBatis 的 Mapper 对象,Service 里才能直接通过@Autowired注入。注意事务管理器默认回滚运行时异常而不回滚受检异常,后面在写比分更新逻辑时我会用RuntimeException来触发回滚。
3. SSM 后端实现与核心业务逻辑:Mapper、事务与赛程编排
3.1 Maven 项目结构与依赖清单
SSM 项目通常打包成 war 部署在 Tomcat 上,所以 maven 坐标的 packaging 是 war。项目结构上分成 Controller、Service、Dao 三层,外加 resources 目录放配置和 Mapper XML。下面是一个最小可运行的项目骨架。
src/main/java com.pingpong controller/ TournamentController.java MatchController.java service/ TournamentService.java MatchService.java dao/ MatchScheduleMapper.java MatchScoreMapper.java entity/ Tournament.java MatchSchedule.java MatchScore.java User.java common/ Result.java 统一返回体 src/main/resources jdbc.properties spring-context.xml spring-mvc.xml mybatis-config.xml mapper/ MatchScheduleMapper.xml MatchScoreMapper.xml src/main/webapp WEB-INF/views/ index.jsp match.jsp player.jsp这里关键点有三个:一是统一返回体 Result 类,包含 code、message、data 三个字段,所有 Controller 都返回这个对象,前端用 jQuery 判断 code 是否为 0,比返回字符串再解析要清晰得多;二是 Dao 层接口和 resources/mapper 下的 XML 文件名要一一对应,MyBatis 通过命名空间和接口全限定名绑定;三是视图放在 WEB-INF 下,不允许浏览器直接访问 jsp,只能通过 Controller 转发进入页面。
pom.xml 里需要引入 spring-webmvc、spring-jdbc、mybatis、mybatis-spring、mysql-connector-j、druid、jackson-databind、javax.servlet-api(provided 作用域)。这几个坐标缺一不可,少了 Jackson 前端就无法解析 JSON 响应,少了 mybatis-spring 则 MyBatis 无法被 Spring 容器管理。
3.2 用 MyBatis 的 Mapper 接口和 XML 实现 CRUD 与联表查询
先定义 Mapper 接口,方法名和 XML 里的语句 id 一一对应。
public interface MatchScheduleMapper { int insert(MatchSchedule ms); int deleteByTournamentId(Integer tournamentId); MatchSchedule selectById(Integer id); List<MatchSchedule> selectByTournamentId(Integer tournamentId); List<MatchSchedule> selectFinishedByTournament(Integer tournamentId); int updateWinner(@Param("id") Integer id, @Param("winner") Integer winner); int batchInsert(@Param("list") List<MatchSchedule> list); }对应的 XML 里最关键的是 resultMap 和批量插入。
<mapper namespace="com.pingpong.dao.MatchScheduleMapper"> <resultMap id="scheduleMap" type="com.pingpong.entity.MatchSchedule"> <id property="id" column="id"/> <result property="tournamentId" column="tournament_id"/> <result property="roundNo" column="round_no"/> <result property="playerA" column="player_a"/> <result property="playerB" column="player_b"/> <result property="matchTime" column="match_time"/> <result property="tableNo" column="table_no"/> <result property="status" column="status"/> <result property="winner" column="winner"/> </resultMap> <insert id="insert" parameterType="com.pingpong.entity.MatchSchedule" useGeneratedKeys="true" keyProperty="id"> INSERT INTO match_schedule(tournament_id, round_no, player_a, player_b, match_time, table_no, status) VALUES(#{tournamentId}, #{roundNo}, #{playerA}, #{playerB}, #{matchTime}, #{tableNo}, 0) </insert> <insert id="batchInsert"> INSERT INTO match_schedule(tournament_id, round_no, player_a, player_b, status) VALUES <foreach collection="list" item="item" separator=","> (#{item.tournamentId}, #{item.roundNo}, #{item.playerA}, #{item.playerB}, 0) </foreach> </insert> <update id="updateWinner"> UPDATE match_schedule SET winner = #{winner}, status = 2 WHERE id = #{id} </update> <select id="selectByTournamentId" resultMap="scheduleMap"> SELECT * FROM match_schedule WHERE tournament_id = #{tournamentId} ORDER BY round_no, table_no </select> </mapper>写 MyBatis XML 时最容易踩的坑是 Java 属性和数据库下划线字段的映射。上面用了 resultMap 显式列出映射关系,这是最稳妥的方式。如果不想写 resultMap,可以在 mybatis-config.xml 里开启mapUnderscoreToCamelCase,让 player_a 自动映射成 playerA。两种方式选一种,不要混用,否则排查字段为 null 的时候要花很多时间。batchInsert 用 foreach 循环拼接 values,适合赛程一键生成场景。
提示:如果查询结果里某个 Java 属性一直是 null,优先检查 resultMap 是否漏了字段,而不是怀疑 SQL 本身。MyBatis 的字段映射错误不会报异常,只会静默返回 null,这种问题在管理系统开发里出现频率最高。
3.3 用 Spring 事务编排完整业务:生成赛程、录入比分、推进晋级
赛程编排是乒乓球比赛管理系统里最值得写清楚的一块。网上很多源码包用的是最朴素的单败淘汰:报名人数确定后,把运动员随机两两配对,胜者进入下一轮。我们不做复杂算法,只提供这种最常见的轮次生成逻辑,把事务边界和代码结构讲清楚。
下面这段 Service 代码展示两个核心方法:录比分和生成下一轮。
@Service public class MatchServiceImpl implements MatchService { @Autowired private MatchScheduleMapper scheduleMapper; @Autowired private MatchScoreMapper scoreMapper; @Override @Transactional(rollbackFor = Exception.class) public void completeMatch(Integer matchId, List<ScoreItem> scores, Integer winner) { if (scores == null || scores.isEmpty()) { throw new RuntimeException("比分记录不能为空"); } // 1. 更新赛程的胜者和状态 scheduleMapper.updateWinner(matchId, winner); // 2. 删除该场比赛旧比分,比分可能被修正后重新提交 scoreMapper.deleteByMatchId(matchId); // 3. 批量写入每局比分 for (ScoreItem item : scores) { MatchScore ms = new MatchScore(); ms.setMatchId(matchId); ms.setSetNo(item.getSetNo()); ms.setScoreA(item.getScoreA()); ms.setScoreB(item.getScoreB()); scoreMapper.insert(ms); } } @Override @Transactional(rollbackFor = Exception.class) public void generateNextRound(Integer tournamentId) { List<MatchSchedule> finishedMatches = scheduleMapper.selectFinishedByTournament(tournamentId); // 取每场比赛的胜者,按顺序两两配对 List<Integer> winners = finishedMatches.stream() .map(MatchSchedule::getWinner) .collect(Collectors.toList()); List<MatchSchedule> nextRound = buildPairings(winners, tournamentId); scheduleMapper.batchInsert(nextRound); } private List<MatchSchedule> buildPairings(List<Integer> ids, Integer tournamentId) { List<MatchSchedule> result = new ArrayList<>(); for (int i = 0; i + 1 < ids.size(); i += 2) { MatchSchedule ms = new MatchSchedule(); ms.setTournamentId(tournamentId); ms.setRoundNo(1); ms.setPlayerA(ids.get(i)); ms.setPlayerB(ids.get(i + 1)); ms.setStatus(0); result.add(ms); } // 人数为奇数时最后一人轮空,player_b 存 0 表示轮空 if (ids.size() % 2 == 1) { MatchSchedule bye = new MatchSchedule(); bye.setTournamentId(tournamentId); bye.setRoundNo(1); bye.setPlayerA(ids.get(ids.size() - 1)); bye.setPlayerB(0); bye.setStatus(0); result.add(bye); } return result; } }这段代码里有几个决定系统健壮性的点:第一,completeMatch 方法必须用事务包住,因为更新赛程、删除旧比分、插入新比分三个操作不能拆开,一旦第 3 步插入失败,第 1 步已经把比赛标记成已结束,就会出现“比赛结束了却没有比分”的脏数据。注意注解写的是 rollbackFor = Exception.class,因为 Spring 默认只回滚 RuntimeException,如果把异常处理成自定义的 checked exception,事务可能不会回滚。
第二,比分的修改规则用了“先删后插”,而不是逐条 update。因为前端提交时可能调整局数,比如从三局改成五局,逐条 update 会留下多余的局记录,先删再插可以保证数据始终和页面提交的完全一致。代价是比分表的自增 id 会跳号,这对业务没有影响。如果不想跳号,可以改成先查再删多出的局,但代码复杂度会上升,我个人倾向于先删后插。
第三,赛程生成里的轮空处理:player_b 为 0 表示轮空。业务上轮空比赛不需要录比分,前端要隐藏这一场的录比分入口,否则裁判会困惑。这里的 buildPairings 只做了最基础的随机配对,真正的系统可以在报名阶段抽签排序后固定对阵表,原理一致,只是调整选手列表的顺序。
4. 前端页面与系统联调:JSP、Ajax 与比分提交
4.1 JSP 页面、统一返回体和接口约定
SSM 项目的前端常见做法是 JSP + jQuery,不需要引入 Vue 或 React。原因很简单:页面数量少,逻辑集中在几个列表和表单页,jQuery 的 Ajax 写法足够直白,而且 JSP 可以直接用 JSTL 渲染服务端数据,减少一次接口请求。这个系统的前端页面可以分为四块:登录页、赛事管理页、报名管理页、赛程与比分页。为了不给联调制造麻烦,所有 Ajax 接口统一返回 Result JSON 结构,code 为 0 表示业务成功,非 0 表示失败,message 里放可直接展示的提示文字。
前后端接口约定如下:
| 功能 | 接口 | 方法 | 参数 | 返回 |
|---|---|---|---|---|
| 登录 | /user/login | POST | username, password | code/message |
| 创建赛事 | /tournament/add | POST | title, type, startDate, endDate | code/data |
| 报名 | /tournament/entry | POST | tournamentId, playerId | code/message |
| 赛程列表 | /match/list | GET | tournamentId | code/data |
| 比分提交 | /match/score | POST | matchId, winner, scores | code/message |
Controller 接收参数时尽量用 @RequestParam 绑定简单字段,而不是创建大量 DTO。对象参数在参数多、字段嵌套时自然用 POJO 接收,比如录比分时通常传一个包含局分数组的对象。写 Controller 时要保持简洁:只解析参数、调用 Service、返回 Result,具体的业务判断放到 Service 层,这样后续排查问题只需要看关键层。下面给出录比分接口的 Controller 写法。
@Controller @RequestMapping("/match") public class MatchController { @Autowired private MatchService matchService; @ResponseBody @PostMapping("/score") public Result submitScore(@RequestBody ScoreSubmitVO vo) { if (vo.getMatchId() == null || vo.getScores().isEmpty()) { return Result.error("缺少比分参数"); } try { matchService.completeMatch(vo.getMatchId(), vo.getScores(), vo.getWinner()); return Result.ok(); } catch (Exception e) { return Result.error(e.getMessage()); } } }这里有两个细节容易忽略。第一,接口方法签名用了 @RequestBody,表示请求体的 JSON 字符串会反序列化成 ScoreSubmitVO 对象。为此需要在 spring-mvc.xml 里配置 MappingJackson2HttpMessageConverter,并注册注解驱动。很多 SSM 项目出现“前端传了 JSON 但后台收到 null”的问题,根源就是只写了 @Controller 而没有配置注解驱动,消息转换器没有生效。第二,Service 抛出的异常在 Controller 里被捕获并转成 Result.error,这样异常信息可以直接回显给前端用户,而不是让浏览器看到一片堆栈。事务回滚仍然有效,因为异常是在 Service 内部抛出并终结的,捕获点在外面。
4.2 赛程与比分页面的 Ajax 实现
录比分页面是前后端联调里最容易出错的场景。页面加载时要先查赛程列表,点击某场比赛后弹出一个表单,表单里有若干局比分输入框,提交时把所有局分组装成数组发给后端。下面给一个最小可用的录比分页面片段。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>赛程管理</title> <script src="${pageContext.request.contextPath}/static/js/jquery-3.6.0.min.js"></script> </head> <body> <h3>赛程列表</h3> <table id="matchTable" border="1"> <thead> <tr><th>轮次</th><th>选手A</th><th>选手B</th><th>状态</th><th>操作</th></tr> </thead> <tbody></tbody> </table> <script> var ctx = '${pageContext.request.contextPath}'; $(function () { loadMatches(); }); function loadMatches() { $.get(ctx + '/match/list', {tournamentId: 1}, function (res) { if (res.code !== 0) return; var rows = ''; $.each(res.data, function (idx, m) { rows += '<tr>' + '<td>' + m.roundNo + '</td>' + '<td>' + m.playerAName + '</td>' + '<td>' + m.playerBName + '</td>' + '<td>' + (m.status === 2 ? '已结束' : '未开始') + '</td>' + '<td><button onclick="submitScore(' + m.id + ')">录比分</button></td>' + '</tr>'; }); $('#matchTable tbody').html(rows); }); } function submitScore(matchId) { var scores = []; // 实际页面会动态生成若干局输入框,这里简化为固定三局 for (var i = 1; i <= 3; i++) { scores.push({setNo: i, scoreA: 11, scoreB: 8}); } var payload = {matchId: matchId, winner: 12, scores: scores}; $.ajax({ url: ctx + '/match/score', type: 'POST', contentType: 'application/json;charset=UTF-8', data: JSON.stringify(payload), success: function (res) { alert(res.message); } }); } </script> </body> </html>这个片段里的 m.playerAName 是前端展示时需要的一个额外字段,它本身不在 match_schedule 表里。常见做法是 Mapper 联查 player 表得到姓名,组装到返回的实体中,而不是让前端拿着 player_id 再去发一次请求。一次查询尽量把展示需要的字段都带出来,这是前后端联调的基本默契。
录比分的提交函数把每局比分构造成数组,通过 JSON.stringify 发送。注意 Ajax 的 content-type 要设成 application/json,并且把 @ResponseBody 和 @RequestBody 配对使用:后端用 @RequestBody 接收 JSON,就必须发送原始 JSON 字符串,而不是 form 表单格式。这两个配置经常被写反,前端用默认的表单序列化,后端却等着 JSON 对象,结果就是报 415 错误。
5. 源码打包、Tomcat 部署与线上排错技巧
5.1 用 Maven 打 war 包并在 Tomcat 上部署
代码写完、本地测试通过后,交付的方式是打成 war 包部署到 Tomcat。常见做法是用 Maven 的 package 命令构建。
mvn clean package -DskipTests cp target/pingpong.war /opt/tomcat/webapps/ /opt/tomcat/bin/startup.sh tail -f /opt/tomcat/logs/catalina.out打包前先检查 maven-compiler-plugin 的 source/target 是否匹配本机 JDK 版本。如果代码里用了 Java 8 的 lambda 和 stream,编译版本低于 1.8 会直接失败。war 包部署到 Tomcat 的 webapps 目录后,Tomcat 会自动解压。访问路径是http://服务器IP:8080/pingpong/,如果你的项目名变了,这个上下文路径也会跟着变。如果不想用 war 包解压后的目录名,可以把 war 改名为 ROOT.war,这样直接用根路径访问。部署后最先看 catalina.out 的日志,绝大多数启动失败在里面都能找到线索。
5.2 线上最常见的几类问题及对策
| 报错或现象 | 根本原因 | 对策 |
|---|---|---|
| 启动报 NoClassDefFoundError | Java 依赖没有打进 war 包 | 检查 pom.xml 依赖 scope,MySQL 驱动不要用 provided |
| 查询中文乱码 | 数据库、连接 URL、页面编码不一致 | 统一 utf8mb4,JSP 顶部加 pageEncoding="UTF-8" |
| Mapped Statements collection does not contain value | Mapper XML 的 namespace 或文件名写错 | 检查 namespace 与接口全限定名是否一致 |
| 接口返回 415 | 前端 content-type 不对或没配消息转换器 | 前端用 application/json,spring-mvc.xml 开启注解驱动 |
| Too many connections | 连接池配置过大或连接泄漏 | 压测后调小 maxActive,检查慢查询释放连接 |
| 时间字段差 8 小时 | JDBC URL 缺少 serverTimezone 参数 | 补上 serverTimezone=Asia/Shanghai |
5.3 验证数据正确性的几条 SQL 和后续优化方向
部署完成并录入几场比赛后,需要验证系统数据是否可靠。下面几条 SQL 可以直接在 mysql 命令行里跑,用来检查赛程和比分是否一致。
-- 查某赛事已结束但没录比分的比赛,应该是空 SELECT sa.id, sa.round_no FROM match_schedule sa LEFT JOIN (SELECT DISTINCT match_id FROM match_score) sc ON sa.id = sc.match_id WHERE sa.tournament_id = 1 AND sa.status = 2 AND sc.match_id IS NULL; -- 统计每位运动员的胜场数,和积分排名页做核对 SELECT winner, COUNT(*) AS win_count FROM match_schedule WHERE tournament_id = 1 AND status = 2 GROUP BY winner ORDER BY win_count DESC;第一条利用左连接查缺口,把“已结束但没比分”的脏数据暴露出来,这类数据在录比分接口缺少事务保护时最容易出现。第二条统计胜场数,可以核对比赛结果和积分排名是否合理。
再往深走,可以在 Service 层加一个定时任务,每晚扫描一遍有没有“状态为已结束但没有比分”的记录,把异常数据写进告警表,这样系统就有了自检能力。比分分析上,通过 match_score 按局统计双方净胜分,可以计算小分排名,这部分用一条 group by 就能完成,直接利用前面建的 match_score 表即可,不需要额外加列。
本文还有配套的精品资源,点击获取