简介这是一份计算机专业课程设计文档主题为在线投票系统的设计与实现面向JSP/JavaWeb初学者、高校课设及毕业设计参考人群。文档以MyEclipse为开发环境结合JSP与数据库技术完整呈现了需求分析、系统设计、数据库结构规划和JavaBean封装等开发环节。核心内容涵盖投票列表展示、用户投票操作、结果柱形图查看以及同一IP重复投票限制三大功能模块并对tb_temp与tb_vote两张数据表及VoteSingle、TempSingle两个值JavaBean进行了详细说明。资源仅包含1个PDF文件压缩包大小188KB篇幅紧凑、便于快速通读。目前已有81人学习下载。读者可通过这份资料直观了解传统JavaWeb项目的分层实现思路包括如何在业务逻辑与表现层之间传递数据、如何通过数据库操作类完成查询与更新为独立完成类似投票系统或深入理解J2EE开发流程提供具体参考。1. 把在线投票系统当课设来做先想清楚它为什么被反复布置很多计算机专业的学生拿到“在线投票系统”这个题目第一反应是“这不就是增删改查吗”然后按商品管理系统的方式去写三个表、两个表单、一个列表页最后拿出去演示。等到答辩或者验收的时候才发现老师真正要看的不是界面好不好看而是数据一致性、并发控制、幂等设计这些在课本上反复出现却很难用一个普通 CRUD 项目证明的点。在线投票系统恰好是这几个知识点的最小载体投票既是写操作又是统计操作既要保证一个人只投一次票又要保证计票结果在并发场景下不出错还要把结果可视化到图表里。这篇文章按课程设计的完整推进顺序来写——数据建模、接口设计、前端渲染、防作弊与验收技巧每一章都给出可以照搬的表结构、代码和参数设置。目标读者是正在做课设的本科生以及需要帮学生做技术评审的助教和工程师。全文不依赖特定框架后端示例用 Java Spring Boot 与 MyBatis 给出前端用 Vue 3 与 ECharts但你完全可以把同样的思路平移到 Python Flask、Go Gin 或者纯 Servlet 上。2. 在线投票系统的数据建模从投票主题到投票记录的字段取舍2.1 三张核心表的关系设计与外键约束在线投票系统的最小数据模型是三张表主题表、选项表、投票记录表。主题表存“这场投票叫什么、什么时候结束”选项表存“这个主题下有哪些候选项”投票记录表存“谁在什么时间投给了哪个选项”。三张表的关系非常明确主题一对多选项选项一对多记录用户与记录之间则是“一个用户在一个主题下只能出现一次”。先用 MySQL 建表语句把这套模型固定下来。CREATE TABLE vote_topic ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT 投票标题, description VARCHAR(500) DEFAULT COMMENT 投票描述, start_time DATETIME NOT NULL COMMENT 开始时间, end_time DATETIME NOT NULL COMMENT 结束时间, status TINYINT NOT NULL DEFAULT 0 COMMENT 0未开始 1进行中 2已结束, allow_anonymous TINYINT NOT NULL DEFAULT 1 COMMENT 是否允许匿名投票, max_choices TINYINT NOT NULL DEFAULT 1 COMMENT 最多可选几项, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_status_end_time (status, end_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票主题表; CREATE TABLE vote_item ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, topic_id BIGINT UNSIGNED NOT NULL, item_name VARCHAR(200) NOT NULL COMMENT 选项名称, sort_order INT NOT NULL DEFAULT 0 COMMENT 显示顺序, vote_count INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 冗余计数字段, PRIMARY KEY (id), KEY idx_topic_id (topic_id), CONSTRAINT fk_item_topic FOREIGN KEY (topic_id) REFERENCES vote_topic (id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票选项表; CREATE TABLE vote_record ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, topic_id BIGINT UNSIGNED NOT NULL, item_id BIGINT UNSIGNED NOT NULL, voter_ident VARCHAR(64) NOT NULL COMMENT 投票人标识学号或会话ID, voted_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_topic_voter (topic_id, voter_ident), KEY idx_item_id (item_id), CONSTRAINT fk_record_topic FOREIGN KEY (topic_id) REFERENCES vote_topic (id) ON DELETE CASCADE, CONSTRAINT fk_record_item FOREIGN KEY (item_id) REFERENCES vote_item (id) ON DELETE CASCADE ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT投票记录表;这段建表逻辑里最值得解释的是vote_record上的联合唯一索引uk_topic_voter。它把“一个投票人只能在这个主题下投一次”的约束直接下沉到数据库层比在 Service 层先查再插要可靠得多。课设答辩时如果老师问“你怎么防重复投票”你先答出来这个唯一索引再补充说应用层还有前置校验就已经把这道题的基本分拿到了。vote_item表里保留了vote_count这个冗余计数字段这是有意的设计取舍。每次投票时先UPDATE vote_item SET vote_count vote_count 1 WHERE id ?查询结果页时直接读这个字段避免每次都用COUNT(*)去扫vote_record表。代价是计数字段与记录表可能短暂不一致所以后面的接口章节会专门讲如何用事务包住“插记录 对选项计数置为原子操作”的两步写操作。2.2 字段类型、默认值与会话级唯一索引的选型在线投票系统课设里经常出现两类低级错误。第一类是时间字段用VARCHAR存字符串导致后面无法直接用 SQL 比较时间大小第二类是投票人标识字段长度开得不够或者干脆用自增主键当投票人 ID导致唯一约束形同虚设。我的习惯是时间一律用DATETIME会话标识统一用学号或用户 ID 的哈希值长度按 64 预留。max_choices这个字段容易被忽视但它直接决定了投票逻辑的复杂度。如果max_choices 1投票接口收到的参数就是一个itemId如果大于 1参数就变成一个itemId数组事务里要对数组做循环插入并且要校验数组长度不超过max_choices。很多课设做的是“每人限投一票”的简化版字段仍然建议保留因为老师很可能现场问你一句“如果我改成一个多选投票你要改哪些地方”vote_topic的status字段建议不要完全依赖定时任务去更新而是在查询时根据当前时间动态推导投票是否可投。SELECT id, title, max_choices, CASE WHEN NOW() start_time THEN 0 WHEN NOW() end_time THEN 2 ELSE 1 END AS current_status FROM vote_topic WHERE id #{topicId};动态计算状态的好处是省掉了一个定时扫描表、批量更新状态的 Job也避免因为定时任务没跑而出现“投票时间已经结束但页面还能投”的尴尬。索引idx_status_end_time只用于后台管理页的列表筛选前台详情页永远按主键查。2.3 常用的 ER 图设计检查点课设报告里必然要放一章 ER 图但很多人的图和实际建表语句对不上。用上面三张表可以画出一个最基础的 ER 图关系集合vote_topic与vote_item是 1:Nvote_item与vote_record是 1:Nvote_topic与vote_record通过vote_item形成间接的 1:N。外键全部设置ON DELETE CASCADE好处是后台删除一个投票主题时MySQL 会自动清理关联选项和投票记录不需要逐表手写 DELETE。检查点要求常见错误主键策略全部使用自增 BIGINT不用 UUID 做主键用 UUID 导致 B 树页分裂性能下降唯一约束投票记录表必须有 (topic_id, voter_ident) 联合唯一只建单列索引挡不住同一个用户重复投字符集统一 utf8mb4不混用 utf8mb4_general_ci 与 ascii存 emoji 或生僻字时直接报错或乱码软删除主题表建议加is_deleted记录表不加直接物理删除导致统计结果对不上审计日志时间精度统一 DATETIME(0)不混用 TIMESTAMP浏览器与服务器时区不同投票时间显示错位这个检查表也可以直接放进课设报告的数据字典章节每一条对应一段文字解释。重点写清楚为什么投票记录表不设is_deleted投票结果应当具备不可抵赖性和可审计性一旦删除就破坏了投票的严肃性如果需要撤销某张票正确的做法是插入一条类型为“撤销”的额外记录而不是物理删除原有记录。3. 在线投票系统的接口设计与并发写入控制3.1 投票接口的幂等设计与状态码约定投票接口是整个系统的核心它的核心诉求有两个幂等和原子。幂等指的是同一个投票请求重复提交最终效果只相当于一次投票原子指的是“插入投票记录”和“选项计数加一”这两步操作要么同时成功要么同时失败不能出现记录插进去了但计数没变的情况。接口设计上我建议把投票动作收敛成POST /api/v1/topics/{topicId}/vote请求体如下。{ itemIds: [1, 3], voterIdent: 20240001 }voterIdent在真实系统里应该由服务端从登录态中获取而不是由前端传过来否则任何人都可以把请求体改成别人的学号。课设为了演示方便可以让前端传但要在答辩时主动说明这一点并展示服务端对voterIdent的合法性校验逻辑。Service 层的核心代码逻辑可以抽象成下面这段伪代码。Transactional(rollbackFor Exception.class) public void vote(VoteRequest request) { VoteTopic topic voteTopicMapper.selectById(request.getTopicId()); if (topic null) { throw new BizException(投票主题不存在); } // 动态判断投票时间而不是只依赖 status 字段 if (LocalDateTime.now().isBefore(topic.getStartTime()) || LocalDateTime.now().isAfter(topic.getEndTime())) { throw new BizException(当前不在投票时间范围内); } if (request.getItemIds().size() topic.getMaxChoices()) { throw new BizException(超出最多可选数量); } // 核心幂等保护先尝试插入记录被唯一索引挡住的直接视为重复投票 for (Long itemId : request.getItemIds()) { VoteRecord record new VoteRecord(); record.setTopicId(request.getTopicId()); record.setItemId(itemId); record.setVoterIdent(request.getVoterIdent()); try { voteRecordMapper.insert(record); } catch (DuplicateKeyException e) { throw new BizException(您已经参与过该投票, e); } voteItemMapper.increaseVoteCount(request.getTopicId(), itemId); } }这段代码里最关键的一点是先插记录后加计数并且用唯一索引兜底。如果先更新vote_count再插入记录一旦插记录失败事务回滚会把计数更新也回滚掉其实也能保证一致性但会出现“计数更新了但事务还没提交”的锁等待时间先插后更在语义上更直观也与“投票行为产生投票记录再反映到计数”的顺序一致。increaseVoteCount对应的 SQL 是UPDATE vote_item SET vote_count vote_count 1 WHERE id ?。这一步利用了 MySQL 的原子自增能力不是先SELECT拿旧值再UPDATE因为那样在并发场景下必然丢更新。3.2 事务边界与锁的选择悲观锁兜底唯一索引做第一道防线上面这段代码里因为有Transactional所以循环内有多条记录和多条更新时全部在一个事务里。但有学生问过一个问题如果同一个请求传了两个itemId而这两个itemId分别属于不同的投票主题事务会不会出问题答案是不会因为代码里根本没有校验itemId是否属于当前topicId。这是一个必须堵上的逻辑漏洞否则攻击者可以构造跨主题的请求把 A 主题的选项 ID 塞进 B 主题的投票请求里。正确的做法是在循环里加一个校验使得voteItemMapper.selectByIdAndTopicId(itemId, topicId)查不到就说明参数非法。这一条建议放在代码评审的重点里。当多个用户同时对同一个选项投票时行级锁的竞争会集中在vote_item表对应行的vote_count更新上这是正常的。InnoDB 的默认隔离级别是REPEATABLE READUPDATE语句自身带行锁所以不会出现两个事务把vote_count同时加出同一个值的情况。真正需要担心的反而是另一个问题两个事务都执行完voteRecordMapper.insert后其中一个因为唯一索引冲突而回滚时vote_count的自增更新也一起回滚整体数据依然一致。如果想把并发控制表现得更“高级”可以在插入记录前先执行SELECT ... FOR UPDATE对主题行加锁但这在同一个用户重复投票的场景下收益不大还会放大锁粒度把一个主题下的所有投票请求串行化。课设不建议加这层锁更推荐依靠唯一索引加事务。3.3 查询结果接口一次拿主题、选项和票数结果接口与投票接口合在一个 Controller 里路径建议设计为GET /api/v1/topics/{topicId}/result返回数据结构直接匹配前端图表组件。{ topicId: 1001, title: 2025 年最美校园建筑评选, totalVotes: 256, items: [ { itemId: 1, itemName: 图书馆, voteCount: 128, percent: 50.0 }, { itemId: 2, itemName: 教学楼, voteCount: 72, percent: 28.1 } ] }totalVotes可以由前端根据items数组里的voteCount求和得出也可以在 SQL 里用一条SUM聚合。按条件查询和一个ORDER BY vote_count DESC建议放在同一个 SQL 里完成注意按票数降序排列后前端饼图会更好地展示最高选项。SELECT i.id AS item_id, i.item_name, i.vote_count, ROUND(i.vote_count / t.total * 100, 1) AS percent FROM vote_item i CROSS JOIN (SELECT SUM(vote_count) AS total FROM vote_item WHERE topic_id #{topicId}) t WHERE i.topic_id #{topicId} ORDER BY i.vote_count DESC;这里用CROSS JOIN带出总票数少一次 Java 层循环SQL 也直观。ROUND保留一位小数前端图表展示时不会再自己算一遍避免前后端精度不一致。4. 在线投票系统的前端交互与结果可视化4.1 投票页与结果页的组件拆分前端代码建议拆成两个页面投票页和结果页。投票页的职责是展示主题、加载选项列表、提交所选选项结果页的职责是拉取结果接口数据并渲染图表。两个页面共用同一个topicId路由参数竞态问题是此处最需要注意的用户投票成功后跳转到结果页结果页可能尚未感知到后端数据更新导致图表短暂闪烁。我一般会把“投票成功后的跳转”设计成先调一次结果接口拿到最新数据后再跳转而不是直接路由跳转。这样虽然多了一次网络请求但用户体验上不会出现白屏或旧数据残留。另外要让voted_at显示格式统一前端用day.js格式化避免不同浏览器对 ISO 时间字符串的解析差异。4.2 用 ECharts 在本地跑通实时刷新投票图表的最小代码结果页的核心是一个 ECharts 柱状图下面这段代码可以直接放进 Vue 组件的onMounted钩子里。import * as echarts from echarts; const chartDom ref(null); let chartInstance null; async function loadResult() { const res await fetch(/api/v1/topics/${topicId}/result); const data await res.json(); const option { grid: { left: 40, right: 20, top: 30, bottom: 30 }, xAxis: { type: category, data: data.items.map(it it.itemName) }, yAxis: { type: value, minInterval: 1 }, series: [{ type: bar, data: data.items.map(it it.voteCount), label: { show: true, position: top }, barMaxWidth: 50 }] }; chartInstance.setOption(option); } onMounted(() { chartInstance echarts.init(chartDom.value); loadResult(); const timer setInterval(loadResult, 10000); onBeforeUnmount(() clearInterval(timer)); });minInterval: 1的作用是保证 Y 轴不会出现 0.5 这种小数刻度票数一定是整数避免图表出现奇怪的中间刻度。barMaxWidth限制柱子最大宽度防止选项名很长、柱子被拉伸得过于夸张。轮询间隔的10000是 10 秒这个数值是拍脑袋定的吗其实是经验值。如果间隔太短比如 1 秒前端会持续占用后端接口资源如果间隔太长比如 60 秒现场演示时老师投票后要等很久才能看到数字跳变。课设场景下 10 秒是一个合理的折中也可以在本地特意把loadResult改成手动触发方便演示时按一下刷新一下。4.3 可视化参数表图表类型、轮询策略与数据口径结果页不止柱状图一种选择饼图适合展示占比关系环形图适合做投票总数展示。ECharts 切换图表类型只需要改series.type和xAxis/yAxis的配置推荐把图表类型抽象成一个枚举值前端下拉框切换时直接调用chartInstance.clear()再setOption不要在同一个 chart 实例上混着设置。参数推荐值说明轮询间隔10000ms课程设计演示平衡实时性与服务端压力图表刷新方式setOption全量替换不调用clear时旧 series 可能残留票数为 0 的选项保留并显示 0不隐藏避免用户误以为后台漏数据百分比精度前端保留 1 位小数与后端ROUND口径一致排序方向按票数降序让最高选项默认出现在柱状图最左侧还有一处容易踩的坑ECharts 在容器宽度为 0 或者display: none的页签里初始化时图表无法正常渲染。如果结果页用了tab切换必须在 tab 切换事件里调用chartInstance.resize()否则图表宽高是初始值显示损坏。5. 在线投票系统的课设验收自查与防刷细节5.1 五个容易在验收现场翻车的点课设验收和线上生产系统最大的区别是老师会在你电脑上现场点数据。他可能开两个浏览器窗口同时投票可能在投票时间截止前一秒提交可能把一个选项的投票数据复制到另一个选项上。针对这些场景我把自查清单压缩成五个最容易翻车的点。第一点虽然代码里多了唯一索引但很多同学在演示“重复投票”时用的是同一个浏览器voterIdent却在每次刷新页面后变化因为用了UUID.randomUUID()来生成。这会让老师看到明明点了一次却显示“投票成功”两次。解决方法是让voterIdent在学生未登录时从会话 Cookie 中取一个固定值并且该值的生命周期是整场投票而不是每次请求都变。第二点投票结束时间的校验。不少项目的判断逻辑写成endTime.after(new Date())这段代码没问题但有的同学喜欢在数据库里给end_time直接存字符串或者把日期从浏览器端传入导致后端接收的格式不对。统一用时间戳比对不要用字符串比较。第三点多选项投票时vote_count与vote_record的数量不一致。比如一个主题max_choices 3一个人投了 3 个选项vote_count总票数是 3但参与人数是 1。后端统计结果时如果没有按voter_ident去重总参与人数就是错误的。第四点事务内不准发生交通信。有的同学喜欢在投票事务里调短信验证码或邮件通知这会拉长事务占用数据库连接的时间并可能因外链超时导致整个投票失败。短信通知这种逻辑要放在事务提交后的异步事件里。第五点页面刷新后voted_at显示为NaN。因为后端返回的日期格式是2025-11-20T10:30:00前端直接new Date()解析无问题但如果你把 MySQL 的DATETIME通过 JSON 序列化成2025-11-20 10:30:00部分浏览器解析会报错。建议后端统一返回毫秒时间戳前端再格式化不要靠浏览器猜解析规则。5.2 用 EhCache 或本地缓存做结果接口的轻量加速结果接口访问频率远高于投票接口而且每次轮询都会重新聚合。课设环境下可能只有几十个并发但为了避免每次轮询都访问数据库可以给结果接口加一个 5 秒的本地缓存。# EhCache 3 的依赖坐标把版本号替换为你本地可用的版本 # 如果你用 Maven在 pom.xml 的 dependencies 中加入以下依赖不用刻意引入 Redis课程设计阶段本地缓存就够了。实现思路是在 Service 层对result(topicId)的返回值做缓存缓存 key 是topicId缓存时间为5秒然后在投票成功后主动evict该 key。本地方案在单机部署下不会产生一致性问题而且哪怕缓存里有 5 秒的滞后视觉上也不容易察觉。5.3 把课设报告的数据字典与演示脚本对齐在线投票系统的课设报告里数据字典和演示脚本是最后的加分项。数据字典不要原样复制建表语句而要用表格形式列出字段名、类型、约束和备注演示脚本要覆盖“非法用户重复投票返回 2003 错误”“并发 20 个投票请求后 count 精确等于 20”这两个场景并把执行结果截图放进报告附录。最后的建议是把投票按钮的“禁止重复点击”逻辑从前端和后端都做一遍前端用disabled占位后端用唯一索引兜底。这样老师换个方式测试系统都不会穿帮。整个系统最核心的一句话也就在这在线投票系统的课程设计重点不是投票本身而是它的可验证性。本文还有配套的精品资源点击获取