
简介这是一套基于Spring Boot开发的在线投票系统面向需要快速搭建投票/比赛排行场景的Java学习者与开发者。系统覆盖登录注册、忘记密码、首页统计展示、实时投票、比赛排行榜、参赛作品投票、个人信息及修改密码等完整功能链路。技术栈采用SpringBootSpringSecurityThymeleafBootstrapMybatis/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 LambdaQueryWrapperUsers() .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、/forgotpermitAll登录、注册、找回密码/css/、/js/permitAll静态资源/vote/**authenticated投票、实时统计/user/**authenticated个人信息、修改密码/logoutpermitAll退出登录项目里用 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 LambdaQueryWrapperUsers() .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)可以是自定义 SQLUpdate(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 ListMapString, Object getMatchRanking(Long matchId) { return scoresMapper.selectMaps( new QueryWrapperScores() .select(player_id, COUNT(*) AS vote_count) .eq(match_id, matchId) .groupBy(player_id) .orderByDesc(vote_count)); }这里的selectMaps返回ListMapString, Object每条 Map 里包含player_id和vote_count两个字段。如果写成selectListMyBatis-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 QueryWrapperScores().eq(match_id, matchId)); Long totalPlayers playersMapper.selectCount( new QueryWrapperPlayers().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 classlist-group th:eachrank : ${rankingList} a classlist-group-item d-flex justify-content-between align-items-center span th:text${rank.playerName}选手名/span span classbadge badge-primary th:text${rank.voteCount} 票0 票/span /a /divth:eachrank : ${rankingList}等价于 Java for-each每次迭代都会生成一个a标签。th:text${rank.voteCount} 票是字符串拼接注意 Thymeleaf 的两边要有空格否则模板解析会把数字和中文挤在一起显示。页面里如果出现“${...} 不显示”的问题多半是 Controller 返回的 model 属性名和模板里不一致。4.2 Bootstrap 实时进度条与投票按钮交互实时投票显示不只是“点击后跳转新页面”更好的是通过 Bootstrap 进度条让用户看到当前票数变化。我一般在模板里把进度条宽度设为变量然后通过 AJAX 轮询接口更新。div classprogress div classprogress-bar stylewidth: 0%; th:stylewidth: ${player.percent} %/div /div button typebutton classbtn btn-primary onclickvotePlayer(1, 10)投他一票/buttonth: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 处理如果项目没有关闭 CSRFThymeleaf 渲染的表单必须携带 token。Spring Security 默认把 token 放在 request attribute_csrf里用隐藏域就能注入input typehidden th:name${_csrf.parameterName} th:value${_csrf.token}/parameterName默认是_csrftoken是随机生成的防伪字符串。这个隐藏域特别容易遗漏尤其是用 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 usernameadminpassword123456 \ -H Content-Type: application/x-www-form-urlencoded curl -b cookie.txt -X POST http://localhost:8080/vote/doVote \ -d matchId1playerId2 \ -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 里的successfalse才是代码逻辑问题。第三行查看首页排行榜能直接确认票数是否被累加。这种 curl 验证方式比单测更接近真实用户行为尤其适合快速排查 Session 共享、Cookie 作用域和接口权限这三类问题。项目里 UsersMapperTest 和 ScoresServiceTest 主要用于验证 Mapper SQL 和 Service 事务无法覆盖 web 层的 session 生命周期所以我每次改完 SecurityConfig 都会先用 curl 跑一遍登录链路。本文还有配套的精品资源点击获取