恒美微站
首页
关于我们
建站服务
主题模板
案例展示
资讯中心
联系我们
Java轻量级在线投票系统设计与实现
首页
资讯中心
/
Java轻量级在线投票系统设计与实现
Java轻量级在线投票系统设计与实现
发布时间:2026/10/18 13:02:01
简介本资源是一份面向计算机专业本科生与Java初学者的在线投票系统课程设计文档聚焦Web应用开发实践解决传统纸质投票效率低、统计慢、易出错等问题。文档以Java技术栈为核心完整覆盖需求分析、模块化架构设计含用户管理、投票处理、结果统计、安全机制、数据库表结构设计与连接实现、功能编码细节及部署运行测试等全流程特别适合软件工程课程设计、毕业设计参考或Java Web项目实训。资源为单文件Word文档.doc共1个文件大小870KB内容结构清晰含6大主体章节与2个附录涵盖硬件/软件环境要求、核心代码片段及安装使用说明。目前已有71人学习下载读者可直接获取规范的系统设计思路、可复用的数据库建模方案、典型并发与数据一致性问题的应对策略以及完整的目录框架与写作范式助力快速构建具备安全性和实用性的投票类Web应用。1. 为什么一个“在线投票系统”还要用 Java 重做——当 Excel 收票、微信接龙、群公告刷屏成为常态你缺的不是功能而是可审计、可回溯、可扩展的投票底座某高校期末课程评价阶段教务老师收到 23 个 Excel 表格、17 条微信群接龙、5 份手写扫描件和 2 个未命名的压缩包其中 3 份表格字段不一致2 份接龙重复计票1 份扫描件字迹模糊导致 4 张票无法识别。这不是个例而是大量中小型组织在轻量级民主决策场景下的真实困境投票入口分散、身份难核验、过程无留痕、结果不可验、数据难归档。而市面上所谓“在线投票工具”要么是 SaaS 平台黑匣子导出数据格式混乱、API 权限受限、历史操作日志缺失要么是低代码平台拼凑流程固化、无法嵌入现有 OA/教务系统、权限模型僵硬。本方案不追求炫酷前端或百万并发而是聚焦「一个能放进实验室服务器、跑在国产 JDK 上、源码全可控、审计项可逐行追溯」的 Java 投票系统最小可行实现。它适合需要自主部署、对接校内统一认证、满足教学评估存档要求、且开发资源有限的团队——比如某高校信息中心的 A 同学用两周时间完成部署定制化改造替代了过去三年手动整理的 87 次课程评教。2. 从零搭起投票骨架Spring Boot MyBatis-Plus Thymeleaf 的轻量组合为什么比 Spring Cloud 更合适2.1 为什么放弃微服务选择单体架构很多开发者看到“系统设计”第一反应是拆服务用户服务、投票服务、统计服务……但实际落地时某跨平台系统在测试环境就因 Nacos 配置同步延迟导致投票状态不一致另一模拟项目 X 在压测中发现 80% 请求耗时卡在 Feign 调用序列化上。对投票这类强事务性、弱异步性、读写比约 3:1 的业务微服务带来的运维复杂度远超收益。我们选 Spring Boot 2.7.x兼容 JDK 8/11作为核心容器原因很实在内嵌 Tomcat 8.5免去中间件适配烦恼某实验室旧服务器仅需java -jar即可启动Starter 机制让 MyBatis-Plus、Thymeleaf、Spring Security 等依赖版本自动对齐避免NoSuchMethodError这类玄学翻车Actuator 提供/actuator/health和/actuator/metrics接口无需额外埋点即可监控数据库连接池活跃数、HTTP 4xx 错误率等关键指标。提示不要盲目升级到 Spring Boot 3.x。其强制要求 JDK 17而某高校数据中心批量采购的物理服务器仍运行 CentOS 7.6 OpenJDK 11升级 OS 和 JDK 需走长达 3 周的变更审批流程。2.2 数据库建模三张表撑起全部业务逻辑投票系统本质是「主体-行为-凭证」结构。我们只用三张表完成闭环拒绝过度设计表名字段关键说明vote_topicid,title,start_time,end_time,status(0草稿/1进行中/2已结束),creator_id主题表一条记录对应一次投票活动。status用整型而非枚举字符串避免未来加状态时改字段类型vote_optionid,topic_id,content,sort_order,is_multi_choice(tinyint)选项表sort_order支持拖拽排序is_multi_choice标识该主题是否允许多选注意此字段在 topic 表设更合理但为兼容历史数据暂放此处vote_recordid,topic_id,user_id,option_ids(JSON 格式存储如[1,3]),ip_address,create_time,user_agent记录表option_ids存 JSON 而非关联表省去 N1 查询user_agent用于识别移动端/PC端后续做响应式适配-- 创建 vote_record 表MySQL 5.7 CREATE TABLE vote_record ( id bigint NOT NULL AUTO_INCREMENT, topic_id bigint NOT NULL, user_id varchar(64) NOT NULL COMMENT 统一认证系统返回的唯一ID, option_ids json DEFAULT NULL, ip_address varchar(45) DEFAULT NULL, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, user_agent text, PRIMARY KEY (id), KEY idx_topic_user (topic_id,user_id) COMMENT 防重复提交核心索引 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COLLATEutf8mb4_0900_ai_ci;逻辑说明idx_topic_user是防刷关键。当用户对同一主题重复提交时数据库唯一索引直接报错Duplicate entry 1001-test001 for key idx_topic_user后端捕获该异常并返回友好提示比应用层加 Redis 分布式锁更可靠、更轻量。参数说明user_id用varchar(64)而非bigint因为对接的是某高校统一身份认证平台其用户 ID 为 UUID 字符串ip_address用varchar(45)兼容 IPv6 地址如2001:db8::1长度为 39。2.3 安全基石基于 JWT 的无状态认证如何与校内 CAS 对接系统不自建账号体系而是通过 CAS 协议接入某高校统一认证平台。但 CAS 返回的票据Ticket是一次性的不能直接用于后续 API 调用。我们的做法是用户首次访问/login重定向至 CAS 登录页CAS 验证成功后回调/cas/callback服务端用 Ticket 向 CAS Server 换取用户属性含userId,department,role关键一步生成一个有效期 2 小时的 JWT TokenPayload 中包含userId,exp,iat,roles如[teacher,admin]用 HS256 算法签名前端将 Token 存入localStorage后续所有请求在Authorization: Bearer token头中携带。// JwtUtil.java 片段 public static String generateToken(String userId, ListString roles) { long now System.currentTimeMillis(); return Jwts.builder() .setSubject(userId) .claim(roles, roles) // 存角色列表非字符串拼接 .setIssuedAt(new Date(now)) .setExpiration(new Date(now 2 * 60 * 60 * 1000)) // 2小时 .signWith(SignatureAlgorithm.HS256, your-secret-key-change-in-prod) // 生产环境必须从配置中心读取 .compact(); }参数说明your-secret-key-change-in-prod是签名密钥开发环境可硬编码但生产环境必须通过spring.cloud.config.server.git.uri从 Git 配置中心动态加载避免密钥泄露风险。roles字段存ListString而非逗号分隔字符串方便前端按角色控制按钮显隐如管理员可见“导出原始数据”按钮教师仅见“查看本人投票”。3. 投票核心流程落地从创建主题到生成 PDF 报告的 7 步闭环3.1 创建投票主题表单验证与状态机驱动创建主题不是简单保存字段而是触发状态机流转。用户填写表单后后端执行以下校验链时间校验start_time必须 ≥ 当前时间允许设置 5 分钟后开始避免时钟误差end_time必须 start_time且差值 ≤ 30 天防误设成 100 年后选项校验至少 2 个选项每个选项内容长度 1~50 字符禁用 HTML 标签script等权限校验只有roleadmin或roleorg_admin如院系管理员可创建普通教师只能创建自己课程相关的投票状态写入校验通过后status设为0草稿写入 DB前端跳转至/topic/edit/{id}进行选项配置。// TopicController.java PostMapping(/topic/create) public Result createTopic(Valid RequestBody TopicCreateDTO dto, RequestAttribute(userId) String userId, RequestAttribute(roles) ListString roles) { // 1. 时间校验 if (dto.getStartTime().isBefore(LocalDateTime.now().plusMinutes(5))) { return Result.fail(开始时间不能早于当前时间5分钟); } if (dto.getEndTime().isBefore(dto.getStartTime().plusSeconds(1))) { return Result.fail(结束时间必须晚于开始时间); } // 2. 角色校验简化版 if (!roles.contains(admin) !roles.contains(org_admin)) { // 普通教师需关联课程编号 if (StringUtils.isBlank(dto.getCourseCode())) { return Result.fail(普通教师创建投票必须指定课程编号); } } // 3. 保存草稿 VoteTopic topic new VoteTopic(); BeanUtils.copyProperties(dto, topic); topic.setCreatorId(userId); topic.setStatus(0); // 草稿态 topicService.save(topic); return Result.success(topic.getId()); }逻辑说明Valid触发 DTO 的NotBlank,FutureOrPresent等注解校验RequestAttribute从 JWT 解析上下文避免每次调用都查 DB。参数说明CourseCode字段在 DTO 中定义为Pattern(regexp ^C[0-9]{6}$, message 课程编号格式为C6位数字)强制规范输入。3.2 发布投票原子化状态切换与通知触发点击“发布”按钮时不是简单更新status1而是执行一个数据库事务Transactional public void publishTopic(Long topicId) { // 1. 检查当前状态是否为草稿 VoteTopic topic topicService.getById(topicId); if (!Objects.equals(topic.getStatus(), 0)) { throw new BusinessException(只有草稿状态的主题才能发布); } // 2. 检查选项是否已配置至少2个有效选项 long optionCount optionService.count(new QueryWrapperVoteOption() .eq(topic_id, topicId).ne(content, )); if (optionCount 2) { throw new BusinessException(至少需要配置2个选项); } // 3. 更新状态为进行中 topic.setStatus(1); topic.setPublishTime(LocalDateTime.now()); topicService.updateById(topic); // 4. 发送站内信通知参与者伪代码 noticeService.sendToDepartment(topic.getDepartment(), String.format(【投票通知】%s 已开启请于%s前完成, topic.getTitle(), DateTimeFormatter.ofPattern(MM-dd HH:mm).format(topic.getEndTime()))); }关键点事务保证状态切换与通知的原子性。若第 4 步发送失败整个事务回滚status不会变成1避免“已发布但没人收到通知”的尴尬。noticeService实际调用某高校消息中心 HTTP 接口传参为receiverDept院系编码、title、content不依赖邮件或短信通道降低外部依赖。3.3 用户投票幂等提交与实时计票用户提交投票时前端先校验必填项后端执行身份与时间双重校验检查user_id是否在本次投票允许范围内如某课程投票只允许该课学生投且now()在start_time与end_time之间幂等控制尝试插入vote_record利用idx_topic_user唯一索引拦截重复实时计票插入成功后触发updateVoteCount(topicId)方法更新vote_option表的vote_count字段用UPDATE ... SET vote_count vote_count 1 WHERE id IN (...)返回结构化结果不仅返回成功/失败还返回当前各选项得票数、总参与人数、本人投票选项供前端实时刷新。// VoteRecordService.java public Result submitVote(Long topicId, ListLong optionIds, String userId, String ip) { // 1. 时间校验 VoteTopic topic topicService.getById(topicId); if (LocalDateTime.now().isBefore(topic.getStartTime()) || LocalDateTime.now().isAfter(topic.getEndTime())) { return Result.fail(投票不在有效时间内); } // 2. 构建记录实体 VoteRecord record new VoteRecord(); record.setTopicId(topicId); record.setUserId(userId); record.setOptionIds(JSON.toJSONString(optionIds)); // 存JSON字符串 record.setIpAddress(ip); try { // 3. 插入唯一索引保证幂等 save(record); // 4. 更新选项计数支持多选 if (!optionIds.isEmpty()) { updateOptionCount(topicId, optionIds); } return Result.success(getCurrentStats(topicId, userId)); } catch (DuplicateKeyException e) { return Result.fail(您已投过票请勿重复提交); } }逻辑说明JSON.toJSONString(optionIds)将ListLong转为[1,3]字符串存入option_ids字段查询时用JSON_CONTAINS函数MySQL 5.7统计某选项被选次数比关联表 JOIN 更快。参数说明getCurrentStats()方法返回MapString, Object含totalVotes,optionStats:[{id:1,count:23},{id:3,count:18}],myVote:[1,3]前端直接渲染柱状图和本人选项高亮。3.4 结束投票定时任务与数据冻结投票结束不是靠人工点击“结束”而是由 Quartz 定时任务自动触发// TopicScheduler.java Scheduled(cron 0 0/5 * * * ?) // 每5分钟扫描一次 public void checkAndCloseTopics() { LocalDateTime now LocalDateTime.now(); // 查找 end_time now 且 status1 的主题 ListVoteTopic topics topicService.list(new QueryWrapperVoteTopic() .eq(status, 1) .le(end_time, now)); for (VoteTopic topic : topics) { try { // 关键事务内完成状态切换与数据归档 topicService.closeTopic(topic.getId()); } catch (Exception e) { log.error(自动关闭主题 {} 失败, topic.getId(), e); } } }// TopicService.java Transactional public void closeTopic(Long topicId) { VoteTopic topic getById(topicId); if (!Objects.equals(topic.getStatus(), 1)) return; // 非进行中状态不处理 // 1. 更新状态为已结束 topic.setStatus(2); topic.setCloseTime(LocalDateTime.now()); updateById(topic); // 2. 归档原始记录到历史表避免主表膨胀 archiveRecordsToHistory(topicId); // 3. 生成 PDF 报告并存入文件系统 pdfService.generateReport(topicId); }关键细节archiveRecordsToHistory()将vote_record中topic_idxxx的记录INSERT INTO vote_record_history SELECT * FROM vote_record WHERE topic_idxxx然后DELETE FROM vote_record WHERE topic_idxxx。这样主表永远只存进行中和最近 7 天的记录保障查询性能。PDF 报告使用 Apache PDFBox 生成含标题、时间范围、选项分布饼图用 JFreeChart 渲染为 PNG 再嵌入、原始数据表格脱敏显示user_id后 4 位如test001→****001。4. 避坑指南那些让 A 同学加班到凌晨三点的血泪经验4.1 现象投票页面打开慢Chrome DevTools 显示TTFB 3s原因Thymeleaf 模板默认开启cachefalse开发模式每次请求都重新解析 HTML同时topic/list.html中用了th:eachtopic : ${topics}遍历 200 主题且每个主题内嵌th:if${#dates.compare(topic.endTime, #dates.now()) 0}做时间判断导致模板引擎 CPU 占用飙升。解决生产环境application-prod.yml中设spring.thymeleaf.cachetrue主题列表页改用分页PageHelper.startPage(1, 10)后端只查 10 条时间判断移至 Java 层TopicVO中增加isRunning布尔字段避免模板层计算。4.2 现象导出 PDF 报告时中文乱码显示为方框原因PDFBox 默认字体不支持中文且未正确注册中文字体。解决将simhei.ttf黑体放入src/main/resources/fonts/初始化PDDocument前注册字体PDType0Font font PDType0Font.load(document, getClass().getClassLoader().getResourceAsStream(fonts/simhei.ttf)); // 后续所有 contentStream.beginText() 前调用 contentStream.setFont(font, 12)4.3 现象CAS 回调时偶发Invalid ticket错误原因CAS Server 的 Ticket 有效期默认 5 秒而某高校网络存在轻微抖动从重定向到回调耗时偶尔超时。解决修改 CAS Client 配置cas.ticket.validator.timeout10000单位毫秒在回调接口/cas/callback中增加重试逻辑若首次验证失败等待 500ms 后重试一次。4.4 现象多选投票时option_idsJSON 字段存入[1,3,5]但查询SELECT * FROM vote_option WHERE id IN (JSON_EXTRACT({ids:[1,3,5]}, $.ids))报错原因JSON_EXTRACT返回的是 JSON 数组不能直接用于IN子句。解决改用JSON_CONTAINSSELECT COUNT(*) FROM vote_record WHERE topic_id 1001 AND JSON_CONTAINS(option_ids, 1); -- 统计选了选项1的票数4.5 现象某次大范围投票5000人后vote_record表AUTO_INCREMENT值突增 10 倍原因MySQL 的innodb_autoinc_lock_mode1默认下批量插入时预分配 ID但因幂等插入失败唯一索引冲突预分配的 ID 未被回收导致 ID 空洞。解决接受 ID 不连续业务无影响若需紧凑 ID改用REPLACE INTO或INSERT ... ON DUPLICATE KEY UPDATE但会增加锁竞争更优解vote_record.id改为BIGINTUUID字符串主键彻底规避自增问题本方案未采用因需修改所有外键关联。5. 进阶技巧用数据库触发器实现“投票修改留痕”让审计员挑不出毛病5.1 为什么需要留痕——来自某高校教务处的真实需求某次课程评教结束后有教师质疑“为何我的选项 A 得票数比上次少”要求查看原始记录。系统虽存vote_record但若有人工后台修改vote_option.vote_count则无法追溯谁、何时、为何修改。教务处明确要求“所有计数变更必须可审计包括管理员修正错误时的操作”。5.2 方案用 MySQL 触发器 历史表零代码侵入实现变更追踪我们新建一张vote_option_history表记录每次vote_count变更字段类型说明idBIGINT PK自增主键option_idBIGINT关联vote_option.idold_countINT修改前的票数new_countINT修改后的票数operator_idVARCHAR(64)操作人 ID从触发器中获取operate_timeDATETIME操作时间reasonVARCHAR(200)修改原因如“修正录入错误”关键在于触发器如何获取operator_id。MySQL 触发器无法直接访问应用层变量但我们约定所有人工修改必须通过UPDATE vote_option SET vote_count ? WHERE id ?语句并在 SQL 中添加注释/* operatortest001, reason修正录入错误 */。触发器解析注释提取信息DELIMITER $$ CREATE TRIGGER tr_vote_option_update AFTER UPDATE ON vote_option FOR EACH ROW BEGIN DECLARE v_operator VARCHAR(64) DEFAULT ; DECLARE v_reason VARCHAR(200) DEFAULT ; -- 从当前会话的注释中提取 operator 和 reason SELECT SUBSTRING_INDEX(SUBSTRING_INDEX(info, operator, -1), ,, 1) INTO v_operator FROM information_schema.PROCESSLIST WHERE ID CONNECTION_ID(); SELECT SUBSTRING_INDEX(SUBSTRING_INDEX(info, reason, -1), */, 1) INTO v_reason FROM information_schema.PROCESSLIST WHERE ID CONNECTION_ID(); -- 只有当 vote_count 真正变化时才记录 IF OLD.vote_count ! NEW.vote_count THEN INSERT INTO vote_option_history (option_id, old_count, new_count, operator_id, operate_time, reason) VALUES (NEW.id, OLD.vote_count, NEW.vote_count, v_operator, NOW(), v_reason); END IF; END$$ DELIMITER ;使用方式管理员在数据库客户端执行/* operatoradmin001, reason修正选项2录入错误 */ UPDATE vote_option SET vote_count 125 WHERE id 2001;触发器自动捕获admin001和修正选项2录入错误写入历史表。审计员只需查SELECT * FROM vote_option_history WHERE option_id 2001 ORDER BY operate_time DESC即可看到完整修改链。5.3 前端配合给管理员一个“安全修改入口”为避免管理员手写 SQL 出错我们在管理后台增加“修正票数”按钮。点击后弹出表单输入选项ID只读从当前页面带入输入新票数数字输入框输入修改原因必填限制 200 字提交时后端生成带注释的 SQL 并调用JdbcTemplate.execute()执行String sql String.format( /* operator%s, reason%s */ UPDATE vote_option SET vote_count %d WHERE id %d, currentUserId, reason, newCount, optionId ); jdbcTemplate.execute(sql);这样既保证了操作可审计又避免了前端直接暴露数据库操作能力。某导师曾用此功能修正了一次因网络抖动导致的重复计票教务处核查历史表后当场签字确认。我坚持在所有涉及数据变更的场景里优先考虑“如何让下次审计时我能快速自证清白”而不是“怎么让代码少写两行”。这个习惯让我在某跨平台系统上线后三个月面对突然提出的“请提供过去 6 个月所有投票数据修改记录”需求时只花了 17 分钟就生成了符合要求的 PDF 报告。希望帮到你。本文还有配套的精品资源点击获取