简介:这是一套基于Spring Boot开发的在线投票系统,面向需要快速搭建投票/比赛排行场景的Java学习者与开发者。系统覆盖登录注册、忘记密码、首页统计展示、实时投票、比赛排行榜、参赛作品投票、个人信息及修改密码等完整功能链路。技术栈采用SpringBoot+SpringSecurity+Thymeleaf+Bootstrap+Mybatis/MybatisPlus,运行环境为Java1.8与MySQL5.7,适合作为毕业设计或项目实战参考。资源压缩包共775个文件,约9.86MB,以js、xml、java、class、css、html及sql等类型为主,前端资源、后端代码、Mapper映射与数据库脚本均已包含,目录结构按项目模块组织,源码注释与SQL文件便于对照。目前已有175人浏览学习。资源附带完整源码与数据库文件,可直接导入IDE运行,也便于对照学习SpringSecurity认证授权、Mybatis持久化、Thymeleaf模板渲染等关键实现,同时提供了真实投票业务场景下的排行榜与统计逻辑,可作为二次开发基础,方便在此基础上扩展新功能。
1. 在线投票系统的 Spring Boot 项目骨架与代码结构
我第一次打开这个 Spring Boot 在线投票系统源码时,下意识去找 VoteController,结果看到的却是一堆 MapperTest、ScoresServiceImpl 和 SecurityConfig。这个基于 Java 1.8 + MySQL 5.7 的项目,表面功能是“给参赛作品投票”,真正值得拆解的是 Spring Security 过滤器链、MyBatis-Plus 聚合查询,以及前端 Thymeleaf 与后端数据模型的配合方式。它覆盖了登录、注册、忘记密码、首页统计、实时投票、比赛排行榜、个人信息、修改密码这些完整闭环,后端采用 SpringBoot + SpringSecurity + MyBatis/MyBatisPlus,前端是 Thymeleaf + Bootstrap。对于做 Java 毕设、想快速搭一个投票业务系统,或者正在准备 Java 面试的人来说,从这个小项目入手,比只看 Spring Boot 框架介绍或面试八股文更能理解认证上下文和业务查询是怎么落到代码里的。
2. Spring Security 认证流程与 Users 表设计
2.1 从 Users 实体到 UserDetailsService 实现
登录认证的第一步往往不是 Controller,而是 UserDetailsService。项目里的 Users.class 对应 MySQL 的 users 表,字段至少包含 username、password 和基本资料信息。用 MyBatis-Plus 操作时,我不会让 service 层直接查原生 SQL,而是把查询条件封装成 LambdaQueryWrapper,这样字段名改动时编译器就能发现错误。
@Service public class UserDetailsServiceImpl implements UserDetailsService { private final UsersMapper usersMapper; public UserDetailsServiceImpl(UsersMapper usersMapper) { this.usersMapper = usersMapper; } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { Users user = usersMapper.selectOne( new LambdaQueryWrapper<Users>() .eq(Users::getUsername, username) .last("LIMIT 1")); if (user == null) { throw new UsernameNotFoundException("用户不存在"); } return org.springframework.security.core.userdetails.User .withUsername(user.getUsername()) .password(user.getPassword()) .authorities("ROLE_USER") .build(); } }这里的.eq(Users::getUsername, username)是 MyBatis-Plus 3.x 的 Lambda 写法,作用等价于WHERE username = ?。.last("LIMIT 1")是为了防止数据表里出现重复用户名时 selectOne 直接抛异常。实际上,唯一索引才是最终防线,Mapper 查询只能做兜底。返回的UserDetails对象中,authorities("ROLE_USER")是授权标记,后续接口可以用hasRole("USER")做权限控制。
在 Spring Boot 项目中,这个实现类默认会被 security 的 AuthenticationManager 调用。如果你把密码加密方式从{noop}换成 BCrypt,需要在注册时同步使用同一个 PasswordEncoder,否则会出现“密码错误但代码没改”的经典事故。
2.2 SecurityConfig 放行规则与 BCrypt 密码校验
项目里的 SecurityConfig.class 是 Spring Security 的核心配置类。常见做法是用@EnableWebSecurity关闭默认登录页,然后通过 SecurityFilterChain 定义哪些路径需要登录、哪些路径放行。
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf().disable() .authorizeHttpRequests(auth -> auth .requestMatchers("/", "/home", "/register", "/forgot", "/css/**", "/js/**").permitAll() .requestMatchers("/vote/**", "/user/**").authenticated() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .defaultSuccessUrl("/home", true) .permitAll() ) .logout(logout -> logout .logoutUrl("/logout") .logoutSuccessUrl("/login?logout") .invalidateHttpSession(true) ); return http.build(); } }上面的路径规则可以直接映射到项目功能:登录页/login、注册页/register、忘记密码页/forgot都不需要认证;投票接口/vote/**和用户中心/user/**必须登录。defaultSuccessUrl("/home", true)表示登录成功后固定跳到首页,而不是跳回来源 URL,这样避免用户修改密码后跳回敏感页面。
需要放在表格里对照的表:
| 路径 | 权限 | 功能 |
|---|---|---|
| /login、/register、/forgot | permitAll | 登录、注册、找回密码 |
| /css/、/js/ | permitAll | 静态资源 |
| /vote/** | authenticated | 投票、实时统计 |
| /user/** | authenticated | 个人信息、修改密码 |
| /logout | permitAll | 退出登录 |
项目里用 BCrypt 编码密码,所以注册接口里不能直接user.setPassword(password),而是要调用passwordEncoder.encode(password)。如果你看到数据库里的密码是 60 位以$2a$开头的字符串,说明编码正确;如果是明文,Spring Security 默认是无法直接验证的,除非在配置里加{noop},但生产环境不建议这样。
2.3 注册、忘记密码与 session 失效边界
注册逻辑要注意两点:用户名唯一性校验,以及密码编码时机。我一般会在 service 层先selectCount,再插入,最后处理 DuplicateKeyException,保证并发注册下不会插入两条相同用户名。
public boolean register(Users user) { Long count = usersMapper.selectCount( new LambdaQueryWrapper<Users>() .eq(Users::getUsername, user.getUsername())); if (count > 0) { return false; } user.setPassword(passwordEncoder.encode(user.getPassword())); return usersMapper.insert(user) > 0; }这里的selectCount是 MyBatis-Plus 内置方法,返回 Long,不需要写 XML 映射。注意,count > 0只是业务校验,不能完全替代数据库唯一索引。注册成功后用户需要手动跳转到登录页,而不是自动登录,这样可以用logoutSuccessUrl 保证 session 干净。
忘记密码通常有两种实现:一种是“旧密码验证后重置”,另一种是“通过邮箱/手机验证码重置”。这个项目里的忽略密码功能偏向前者,所以不需要引入消息队列或邮件服务。每次重置密码后应调用SecurityContextHolder.clearContext()清理当前登录状态,避免旧会话继续持有过期凭证。
3. 投票业务、比赛排行榜与 MyBatis-Plus 聚合查询
3.1 VoteServiceImpl 的核心事务
投票不是一个简单的 insert,它涉及 Scores、Players、Matchpk 三张表的联动。项目里的 MatchpkServiceImpl 和 VoteServiceImpl 分别负责比赛配对和投票记录。投票时至少要完成两件事:写入一条 vote 记录,累加对应选手的得票数。
@Override @Transactional(rollbackFor = Exception.class) public boolean vote(Long userId, Long matchId, Long playerId) { Scores score = new Scores(); score.setUserId(userId); score.setMatchId(matchId); score.setPlayerId(playerId); score.setCreateTime(LocalDateTime.now()); try { int inserted = scoresMapper.insert(score); if (inserted == 0) { return false; } return playersMapper.increaseVoteCount(matchId, playerId); } catch (DuplicateKeyException e) { log.warn("重复投票:userId={}, playerId={}", userId, playerId); return false; } }@Transactional(rollbackFor = Exception.class)表示只要出现 SQLException 或自定义异常就回滚,否则投票记录插入成功而选手票数累加失败时,数据会不一致。scoresMapper.insert(score)走 MyBatis-Plus 默认插入,注意 Scores 表要设唯一索引,索引列建议用(user_id, match_id, player_id),这样同一场比赛中同一用户对一个选手只能投一次。
playersMapper.increaseVoteCount(matchId, playerId)可以是自定义 SQL:
@Update("UPDATE players SET vote_count = vote_count + 1 WHERE id = #{playerId} AND match_id = #{matchId}") int increaseVoteCount(@Param("matchId") Long matchId, @Param("playerId") Long playerId);注意,这里增加票数用的是vote_count + 1,而不是从 Java 端取出旧值再加一。原因是数据库行锁比应用层 synchronized 更可靠,多个线程同时更新同一行时,只有UPDATE语句内部会串行执行。如果先SELECT再UPDATE,并发场景下大概率会丢更新。
3.2 排行榜的分组统计查询
比赛排行榜在首页统计展示里最容易写错。常见错误是把所有选手分数都查询出来,然后在 Java 端排序分组。一旦选手数量上千,内存排序会拖慢接口响应。更好的是让 MySQL 直接完成 groupBy 聚合。
public List<Map<String, Object>> getMatchRanking(Long matchId) { return scoresMapper.selectMaps( new QueryWrapper<Scores>() .select("player_id, COUNT(*) AS vote_count") .eq("match_id", matchId) .groupBy("player_id") .orderByDesc("vote_count")); }这里的selectMaps返回List<Map<String, Object>>,每条 Map 里包含player_id和vote_count两个字段。如果写成selectList,MyBatis-Plus 会因为结果里没有vote_count对应的实体属性而报错或丢失字段,这是我第一次跑项目时踩过的坑。
如果你希望排行榜带上选手姓名和头像,可以通过player_id再关联players表。当然也可以直接在scores表冗余快照字段,减少 join。投票项目里 player 数量不大,join 更稳妥:
SELECT p.name, COUNT(s.id) AS vote_count FROM scores s JOIN players p ON s.player_id = p.id WHERE s.match_id = #{matchId} GROUP BY p.id, p.name ORDER BY vote_count DESC LIMIT 10;参数#{matchId}在使用 MyBatis 时由@Param("matchId")提供,这里不能用${},否则会有 SQL 注入风险。LIMIT 10 只取前三页,足够首页“比赛排行榜”场景使用。
3.3 首页统计实时数据与缓存取舍
首页统计要展示总票数、参赛人数、本场实时投票进度。项目里如果用 MyBatis-Plus 每次查询都会产生一次 count 聚合,访问量变大后数据库压力不小。Spring Boot 可以在这里加一层简单缓存,但投票系统的实时性很高,缓存策略需要谨慎。
@Cacheable(value = "voteStats", key = "#matchId") public VoteStatsVO getVoteStats(Long matchId) { Long totalVotes = scoresMapper.selectCount( new QueryWrapper<Scores>().eq("match_id", matchId)); Long totalPlayers = playersMapper.selectCount( new QueryWrapper<Players>().eq("match_id", matchId)); return new VoteStatsVO(totalVotes, totalPlayers); }这段代码使用了 Spring Cache 抽象,前置条件是启动类上有@EnableCaching。key = "#matchId"让每一场比赛的统计结果单独缓存。需要更新的时机是:有人投票成功之后,调用cacheManager.getCache("voteStats").evict(matchId),或者直接在投票方法里调用缓存接口清理。
缓存的好处是首页秒开,坏处是如果忘记清理,榜单会延迟。我的做法是给“首页统计总览”开短 TTL(比如 5 秒),给“我的得票数”用实时查询,给“比赛排行榜”开 30 秒缓存,这样不同模块对实时性的要求可以被明显区分。
| 模块 | 推荐方案 | 实时性 |
|---|---|---|
| 首页总票数统计 | 短 TTL 缓存 | 5 秒延迟 |
| 比赛排行榜 | 短 TTL 缓存 | 30 秒延迟 |
| 用户个人投票结果 | 实时查询 | 秒级一致 |
| 后台管理投票总数 | 实时 count | 严格准确 |
4. Thymeleaf 模板渲染与 Bootstrap 投票操作
4.1 用 th:each 渲染比赛排行
前端页面在 templates 目录下,Controller 返回逻辑视图名后,Thymeleaf 会找到对应 HTML。这个项目里 Bootstrap 负责样式,Thymeleaf 负责把后端数据填进表格和卡片。排行榜渲染是典型循环场景。
<div class="list-group" th:each="rank : ${rankingList}"> <a class="list-group-item d-flex justify-content-between align-items-center"> <span th:text="${rank.playerName}">选手名</span> <span class="badge badge-primary" th:text="${rank.voteCount} + ' 票'">0 票</span> </a> </div>th:each="rank : ${rankingList}"等价于 Java for-each,每次迭代都会生成一个<a>标签。th:text="${rank.voteCount} + ' 票'"是字符串拼接,注意 Thymeleaf 的+两边要有空格,否则模板解析会把数字和中文挤在一起显示。页面里如果出现“${...} 不显示”的问题,多半是 Controller 返回的 model 属性名和模板里不一致。
4.2 Bootstrap 实时进度条与投票按钮交互
实时投票显示不只是“点击后跳转新页面”,更好的是通过 Bootstrap 进度条让用户看到当前票数变化。我一般在模板里把进度条宽度设为变量,然后通过 AJAX 轮询接口更新。
<div class="progress"> <div class="progress-bar" style="width: 0%;" th:style="'width:' + ${player.percent} + '%'"></div> </div> <button type="button" class="btn btn-primary" onclick="votePlayer(1, 10)">投他一票</button>th:style用来动态拼接 CSS 宽度。percent 可以在后端计算,也可以交给前端用票数除以总票数计算。我更推荐后端把 percent 算好,避免前端多人同时打开页面时,因基础数据不同计算出不同的概率。
如果是局部刷新,可以写一个简单 fetch 轮询:
function votePlayer(matchId, playerId) { fetch('/vote/doVote', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: 'matchId=' + matchId + '&playerId=' + playerId }) .then(response => response.json()) .then(data => { if (data.success) { location.reload(); } else { alert(data.message); } }); }这个函数的作用是把比赛编号和选手编号提交到后端。fetch默认不带 session cookie,如果你在项目里发现登录用户点击后提示未登录,检查是否是 CORS 设置把 cookie 干掉了。同源项目下不加credentials也能带 cookie,但如果你拆分端口部署,就需要credentials: 'include',这属于 Spring Boot 前后端分离时需要格外注意的边界。
4.3 表单提交与 CSRF token 处理
如果项目没有关闭 CSRF,Thymeleaf 渲染的表单必须携带 token。Spring Security 默认把 token 放在 request attribute_csrf里,用隐藏域就能注入:
<input type="hidden" th:name="${_csrf.parameterName}" th:value="${_csrf.token}"/>parameterName默认是_csrf,token是随机生成的防伪字符串。这个隐藏域特别容易遗漏,尤其是用 AJAX 提交时,必须手动从 cookie 或 meta 标签取出 token 放进 header。项目如果只是纯模板页面,直接在 form 内放隐藏域最省事。
表格有助于梳理:
| 提交方式 | CSRF 处理 |
|---|---|
| Thymeleaf form 同步提交 | 加隐藏域 |
| AJAX POST | 加 X-CSRF-TOKEN 请求头 |
| 前后端分离 + JWT | 关闭 CSRF 或自定义过滤器 |
| 接口测试 | 临时关闭或携带 token |
如果你用 curl 调试登录后的投票接口,别忘了先访问登录页拿 cookie 和 CSRF token,否则会看到 403 Forbidden 而不是业务错误码。
5. 投票防刷、幂等设计与验证技巧
5.1 用唯一索引兜底幂等
投票系统的核心风险是重复提交。用户连续点两次投票按钮,如果只靠前端 disabled 按钮,可能因为网络重试导致重复请求。后端最可靠的方案是给scores表加唯一索引。
ALTER TABLE scores ADD UNIQUE INDEX uk_user_match_player (user_id, match_id, player_id);当第二次插入触发 DuplicateKeyException 时,业务层只需要捕获该异常并返回“您已投过票”。这里要注意事务边界:外层 Service 方法加了@Transactional,如果 catch 住异常后继续执行 UPDATE,同一次事务可能因为 MySQL 把回滚标记设置为rollback-only而产生 UnexpectedRollbackException。所以捕获异常后,最好让事务方法直接 return,不要在同一个事务里继续做累加操作。
5.2 用 Redis 的 setIfAbsent 做简单防抖
投票系统经常会遇到同一用户对多个选手快速投票的场景。秒级防抖用 Redis 就够,不需要引入复杂分布式锁。
Boolean locked = redisTemplate.opsForValue() .setIfAbsent("vote:lock:" + userId + ":" + matchId, "1", Duration.ofSeconds(3)); if (!Boolean.TRUE.equals(locked)) { return "操作太快,请稍后再试"; }setIfAbsent是原子操作,只有 key 不存在时才能设置成功。这里设置 3 秒过期,可以防止同一个用户在一场比赛内 3 秒内连续点击。业务完成后可以手动删除 key,也可以等它自动过期。需要注意,如果投票接口执行时间超过 3 秒而锁又自动过期,极端情况下用户会重复投票,所以锁过期时间一般要略大于接口最坏执行时间。
5.3 用 curl 验证完整链路
验证在线投票系统是否跑通,不建议直接调用 JUnit 里的 PlayerMapperTest,而是用 curl 模拟真实浏览器会话。先登录拿到 cookie,再访问投票接口,最后查询排行榜。
curl -c cookie.txt -X POST http://localhost:8080/login \ -d "username=admin&password=123456" \ -H "Content-Type: application/x-www-form-urlencoded" curl -b cookie.txt -X POST http://localhost:8080/vote/doVote \ -d "matchId=1&playerId=2" \ -H "Content-Type: application/x-www-form-urlencoded" curl -b cookie.txt http://localhost:8080/home第一行把登录后的 session 保存到 cookie.txt,第二行带着 session 投票。如果第二行返回 403,先考虑 CSRF token;返回 302,多半是登录态失效;返回业务 JSON 里的success=false,才是代码逻辑问题。第三行查看首页排行榜,能直接确认票数是否被累加。
这种 curl 验证方式比单测更接近真实用户行为,尤其适合快速排查 Session 共享、Cookie 作用域和接口权限这三类问题。项目里 UsersMapperTest 和 ScoresServiceTest 主要用于验证 Mapper SQL 和 Service 事务,无法覆盖 web 层的 session 生命周期,所以我每次改完 SecurityConfig 都会先用 curl 跑一遍登录链路。
本文还有配套的精品资源,点击获取