ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

B/S架构网络投票系统设计与实现:身份强绑定与审计可追溯

B/S架构网络投票系统设计与实现:身份强绑定与审计可追溯

简介:这是一套基于ASP+Access技术栈实现的B/S架构网络投票系统源码,面向Web开发初学者与课程设计实践者,解决轻量级在线投票场景下的快速部署与后台管理需求,适用于班级选举、问卷调研、教学实验等中小型应用。资源共56个文件,包含26个ASP服务端脚本(涵盖管理员登录、投票增删改、选项管理、配置修改等核心逻辑)、14个GIF图标与2个JPG/BMP界面素材、1个MDB数据库文件存储投票数据、1个CSS样式表及JS/HTM前端页面,整体压缩包仅116KB,结构紧凑、依赖少、易于本地IIS环境运行。已有197人学习下载,读者可直接获取完整可运行的投票系统工程,含安装说明、功能简介、数据库结构与清晰的模块化目录(如admin_开头的管理页、config配置页、login认证页等),并能深入理解B/S模式下前后端交互、ASP表单处理与Access数据操作的典型实现路径。

1. B/S方式网络投票系统:为什么不用C/S、不选小程序,而坚持用纯Web方案落地?

去年带某高校课程设计时,A同学第一版交上来的是微信小程序投票Demo——UI漂亮、扫码即用,但答辩当天被导师当场叫停:“投票系统核心是身份强绑定、操作可追溯、数据不可篡改,小程序依赖第三方平台鉴权,日志留痕弱,审计链路断在微信侧。”一句话点醒。我们立刻切回B/S架构:浏览器即入口,服务端统一管控用户生命周期、投票状态机、计票逻辑与审计日志。这不是守旧,而是对“谁投了、何时投、投给谁、能否撤回、结果如何生成”这五个刚性问题的工程回应。本方案面向本科毕设、课设及小型组织内部投票场景(如班级评优、社团换届、实验室课题组表决),不追求高并发,但要求流程闭环、逻辑自洽、部署轻量、答辩可演示。技术栈锁定为Spring Boot + Vue2 + MySQL,零外部依赖,单机Docker可跑通全流程,所有模块源码、数据库脚本、接口文档、答辩PPT模板全部打包就绪——你拿到就能改、能跑、能讲清楚每一行为什么这么写。


2. 总体架构设计:四层结构与五大核心模块的职责边界

B/S投票系统不是“前端点按钮、后端加一”的简单累加。它必须把“人-票-规则-结果-审计”五条线拧成一股绳。我通常按四层划分:表现层(Vue)、网关层(Nginx反向代理+JWT校验)、业务层(Spring Boot多模块)、数据层(MySQL主库+可选Redis缓存)。其中业务层拆为五大模块,每个模块有明确输入/输出契约,避免后期功能蔓延导致代码腐化。

2.1 用户认证与权限中心:基于RBAC的三级角色隔离

系统只设三类角色:管理员(全权限)、组织者(创建/结束投票、导出结果)、普通用户(仅参与投票)。不采用Spring Security默认的内存用户表,而是建独立sys_user、sys_role、sys_user_role三张表,支持后期扩展组织架构树。关键设计点在于:所有接口必须携带Authorization: Bearer <token>,且token payload中强制嵌入role_id和org_id(组织ID),避免前端传参伪造角色。

-- sys_user 表关键字段(非全量) CREATE TABLE `sys_user` ( `id` bigint NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录账号', `real_name` varchar(50) NOT NULL COMMENT '真实姓名', `id_card` char(18) DEFAULT NULL COMMENT '身份证号(用于实名核验)', `status` tinyint NOT NULL DEFAULT '1' COMMENT '状态:0禁用 1启用', `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) );

提示:id_card字段非空但允许NULL,是因为部分场景(如学生匿名评教)需关闭实名强制,此时由组织者在创建投票时勾选“是否启用身份证核验”。这个开关直接控制后端校验逻辑分支,而不是删字段。

2.2 投票活动全生命周期管理:状态机驱动,拒绝野蛮状态跳转

投票不是静态页面,而是一个有严格状态流转的过程。我们定义6个状态:DRAFT(草稿)→ PUBLISHED(已发布)→ VOTING(进行中)→ CLOSED(已关闭)→ CANCELED(已取消)→ ARCHIVED(已归档)。状态变更不靠前端按钮触发,而是由后端API根据当前状态+操作类型双重校验:

// VotingService.java 片段 public Result<?> changeStatus(Long votingId, String targetStatus) { Voting voting = votingMapper.selectById(votingId); if (voting == null) return Result.fail("投票不存在"); // 状态机校验:只有从 DRAFT 可发布,从 PUBLISHED 可开启,从 VOTING 可关闭... if (!VotingStatus.isValidTransition(voting.getStatus(), targetStatus)) { return Result.fail("非法状态跳转:" + voting.getStatus() + " → " + targetStatus); } // 关键:状态变更前快照记录(用于审计) votingAuditMapper.insert(new VotingAudit(votingId, voting.getStatus(), targetStatus, SecurityUtils.getCurrentUserId())); voting.setStatus(targetStatus); voting.setUpdateTime(new Date()); votingMapper.updateById(voting); return Result.success(); }

这个设计让答辩时能清晰回答:“如果用户误点‘结束投票’,系统怎么防止?——答:状态机拦截,且每次变更留痕到voting_audit表。”

2.3 候选人与选项动态配置:JSON Schema约束,兼顾灵活性与一致性

候选人不是固定字段(姓名+照片),而是支持动态字段扩展。例如:班干部选举需填“竞选宣言”,而优秀党员评选需填“入党时间”。方案是:组织者创建投票时,先选择预设模板(如“班委选举”“课题组评审”),系统自动加载对应JSON Schema;再填写候选人信息,前端用vue-json-schema渲染表单,后端用jsonschema-validator校验提交数据是否符合Schema。

// 模板 schema 示例:班委选举 { "type": "object", "properties": { "name": { "type": "string", "maxLength": 20 }, "photo_url": { "type": "string", "format": "uri" }, "manifesto": { "type": "string", "maxLength": 500 } }, "required": ["name", "photo_url"] }

注意:候选人数据不存为宽表(避免无限加列),而是存为candidate_info TEXT字段,类型为JSON。查询候选人列表时,用MySQL 5.7+的JSON_EXTRACT()函数提取name做模糊搜索,既保持结构灵活,又不失检索能力。

2.4 投票行为控制引擎:一人一票、时段锁、IP+设备指纹双防重

“一人一票”不是靠前端限制,而是服务端强校验。我们采用三级防重策略:

  • 身份级:同一user_id在同一次投票中只能提交一次(查vote_record表是否存在voting_id + user_id组合);
  • 时段级:投票开启后,若设置“限时投票”,则提交时间必须在start_time与end_time之间(数据库字段voting.start_time/end_time);
  • 设备级:记录User-Agent哈希 +X-Forwarded-ForIP(经Nginx透传),存入vote_device_fingerprint表,同一指纹24小时内不可重复投同一投票。
-- vote_record 表核心索引(保障查重性能) ALTER TABLE `vote_record` ADD UNIQUE INDEX `uk_voting_user` (`voting_id`, `user_id`), ADD INDEX `idx_voting_time` (`voting_id`, `create_time`);

这个设计让答辩时能直面质疑:“如果用户换浏览器重投怎么办?”——答:“设备指纹基于UA+IP生成,换浏览器但IP不变仍被识别;若IP也变(如手机热点),则依赖实名身份证二次核验,组织者可后台手动审核异常记录。”

2.5 实时计票与结果生成:异步聚合+快照固化,拒绝“边投边算”

计票不是实时SQLCOUNT(*),因为高并发下会拖慢投票接口。我们采用“写时记录、读时聚合”策略:

  • 用户提交后,仅插入一条vote_record记录(含voting_id、user_id、option_idsJSON数组);
  • 后台定时任务(Quartz,每5分钟)扫描新投票,调用CountingService.count(votingId)聚合最新结果;
  • 聚合结果存入voting_result_snapshot表,含snapshot_time时间戳,前端展示时查此表而非实时计算。
// CountingService.java @Transactional public void count(Long votingId) { // 1. 查出该投票所有有效记录(排除已撤回、超时等) List<VoteRecord> records = voteRecordMapper.selectValidByVoting(votingId); // 2. 内存聚合(避免多次DB查询) Map<Long, Integer> optionCount = new HashMap<>(); for (VoteRecord r : records) { List<Long> options = JSON.parseArray(r.getOptionIds(), Long.class); for (Long optId : options) { optionCount.merge(optId, 1, Integer::sum); } } // 3. 固化快照(覆盖旧快照,保证原子性) VotingResultSnapshot snapshot = new VotingResultSnapshot(); snapshot.setVotingId(votingId); snapshot.setSnapshotTime(new Date()); snapshot.setResultJson(JSON.toJSONString(optionCount)); snapshotMapper.replace(snapshot); // REPLACE INTO 保证幂等 }

这样做的好处是:答辩演示时,即使现场多人同时投票,结果页刷新看到的永远是“上一个快照时间点”的稳定数据,不会出现数字跳变、计数错乱等玄学问题。


3. 关键实现细节:从登录态穿透到审计日志落盘的七处硬编码点

很多同学卡在“功能能跑,但答辩被问住”。根源在于没搞清哪些逻辑必须硬编码、不能交给框架或配置。以下是本方案中7个必须手写、且直接影响系统可信度的硬编码点,每处都附可直接抄的代码或SQL。

3.1 JWT Token签发时强制注入组织ID与角色ID

Spring Security的UsernamePasswordAuthenticationToken只带用户名,但我们的权限校验需知道“该用户属于哪个组织、以什么角色操作”。因此Token签发必须手动注入:

// JwtTokenUtil.java public String generateToken(UserDetails userDetails, Long orgId, Integer roleId) { Map<String, Object> claims = new HashMap<>(); claims.put("orgId", orgId); // 组织ID,用于多租户隔离 claims.put("roleId", roleId); // 角色ID,用于接口级权限判断 claims.put("username", userDetails.getUsername()); return Jwts.builder() .setClaims(claims) .setSubject(userDetails.getUsername()) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() + TOKEN_EXPIRATION)) .signWith(SignatureAlgorithm.HS512, SECRET) .compact(); }

提示:orgId是关键。若系统未来要支持“同一用户在不同组织有不同角色”,此字段就是租户隔离的基石。答辩时被问“怎么支持学院和班级两级投票?”,就亮这段代码。

3.2 投票创建时自动校验候选人数量与选项数量匹配

组织者可能手误:设置“单选”却添加了5个候选人,但只允许选1个;或设置“多选最多3项”,却只配置了2个候选人。后端必须拦截:

// VotingController.java @PostMapping("/create") public Result<?> create(@RequestBody VotingCreateDTO dto) { // 校验:单选模式下,候选人数量必须 ≥ 1 if (dto.getVoteType().equals("SINGLE") && dto.getCandidates().size() < 1) { return Result.fail("单选模式至少需配置1名候选人"); } // 校验:多选模式下,maxSelect 必须 ≤ 候选人总数 if (dto.getVoteType().equals("MULTI") && dto.getMaxSelect() > dto.getCandidates().size()) { return Result.fail("最多可选数不能超过候选人总数"); } // ...后续保存逻辑 }

这个校验放在Controller层而非前端,是因为前端JS可被绕过。答辩时说:“我们把业务规则守门员放在后端,前端只是友好提示。”

3.3 数据库唯一约束:候选人姓名在同一次投票中不可重复

候选人姓名重复是高频翻车点。比如组织者复制粘贴时多了一行,导致两个候选人同名。MySQL层面加唯一索引最可靠:

-- candidate 表(存储候选人基础信息) ALTER TABLE `candidate` ADD UNIQUE INDEX `uk_voting_name` (`voting_id`, `name`);

注意是联合索引voting_id + name,而非单列name,否则跨投票重名会被误拦。

3.4 审计日志表必须记录操作前后的关键字段值

voting_audit表不能只记“谁在什么时候改了什么状态”,而要记“改之前是什么、改之后变成什么”。例如关闭投票时,需记录原状态VOTING和目标状态CLOSED:

CREATE TABLE `voting_audit` ( `id` bigint NOT NULL AUTO_INCREMENT, `voting_id` bigint NOT NULL, `before_status` varchar(20) NOT NULL COMMENT '操作前状态', `after_status` varchar(20) NOT NULL COMMENT '操作后状态', `operator_id` bigint NOT NULL COMMENT '操作人ID', `operate_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_voting_time` (`voting_id`, `operate_time`) );

提示:答辩时展示此表数据,能直观证明“系统所有关键操作均可追溯”,比讲一百遍“我们有日志”更有力。

3.5 导出Excel结果时强制使用UTF-8 BOM头,解决中文乱码

学生常踩坑:用Apache POI导出Excel,Windows Excel打开是乱码。根本原因是未加BOM头。正确做法:

// ExportService.java public void exportResult(HttpServletResponse response, Long votingId) throws IOException { response.setContentType("application/vnd.ms-excel;charset=utf-8"); response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("投票结果_" + votingId + ".xls", "UTF-8")); // 关键:写入BOM头(\uFEFF) ServletOutputStream out = response.getOutputStream(); out.write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); // 后续用HSSFWorkbook写入数据... }

3.6 前端路由守卫强制校验用户角色,拒绝URL硬跳转

Vue Router的beforeEach必须检查localStorage.getItem('userRole'),而不仅是token存在:

// router/index.js router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') const role = localStorage.getItem('userRole') // 如 'ADMIN', 'ORGANIZER' if (to.meta.requiresAuth && !token) { next('/login') } else if (to.meta.requiresRole && !role) { next('/403') } else if (to.meta.requiresRole && to.meta.role !== role) { next('/403') // 例如 /admin 页面只允许 ADMIN 角色访问 } else { next() } })

没有这一步,用户F12改localStorage就能越权访问。

3.7 MySQL全文索引加速候选人搜索,避免LIKE '%关键词%'

当候选人超百人时,WHERE name LIKE '%张%'会全表扫描。改用全文索引:

-- 在 candidate 表的 name 字段建全文索引 ALTER TABLE `candidate` ADD FULLTEXT(name); -- 查询时用 MATCH AGAINST SELECT * FROM candidate WHERE voting_id = 123 AND MATCH(name) AGAINST('+张*' IN BOOLEAN MODE);

注意:IN BOOLEAN MODE支持+(必须包含)、*(通配符),比LIKE快一个数量级,且支持相关性排序。


4. 避坑指南:上线前必须验证的五个血泪经验点

这套方案在多个课程设计中落地,以下5个问题是学生复现时最高频的翻车点,按“现象→原因→解决”给出可执行方案。别等答辩被问懵了才看。

4.1 现象:登录成功后,首页显示“未登录”,F5刷新才正常

原因:Vue前端从localStorage读取token后,未同步更新Vuex store中的isLogin状态,导致路由守卫和组件渲染不同步。
解决:在登录成功回调里,显式commit mutation:

// store/modules/user.js actions: { login({ commit }, userInfo) { return new Promise((resolve, reject) => { login(userInfo).then(response => { const { token } = response.data localStorage.setItem('token', token) // 关键:同步更新store commit('SET_TOKEN', token) commit('SET_LOGIN_STATUS', true) // 此mutation设 isLogin = true resolve() }).catch(reject) }) } }

4.2 现象:创建投票时,候选人照片上传失败,报400错误

原因:后端Spring Boot默认文件上传大小限制为1MB,而学生拍的候选人照片常超2MB。
解决:在application.yml中显式扩大限制:

spring: servlet: context-path: /vote # 关键:增大文件上传限制 http: multipart: max-file-size: 10MB max-request-size: 10MB

提示:别只改max-file-size,max-request-size也要同步改,否则整个请求体超限仍会400。

4.3 现象:MySQL本地运行正常,Docker部署后投票状态无法更新

原因:Docker容器内MySQL时区为UTC,而Java应用时区为CST,导致NOW()函数返回时间与Javanew Date()不一致,状态机校验失败。
解决:启动MySQL容器时强制指定时区:

docker run -d \ --name mysql-vote \ -e MYSQL_ROOT_PASSWORD=123456 \ -e TZ=Asia/Shanghai \ # 关键:容器时区 -p 3306:3306 \ -v /path/to/data:/var/lib/mysql \ mysql:5.7

并在Spring Boot的JDBC URL中追加时区参数:

spring: datasource: url: jdbc:mysql://localhost:3306/vote_db?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8

4.4 现象:导出Excel时,数字格式变成科学计数法(如13912345678显示为1.39E+10)

原因:POI默认将长数字(如手机号)识别为数值类型,Excel自动转科学计数法。
解决:强制设为文本单元格:

// 创建单元格时指定类型 Cell cell = row.createCell(colIndex); cell.setCellType(CellType.STRING); // 关键:设为字符串 cell.setCellValue("13912345678");

4.5 现象:Vue打包后部署Nginx,访问/login页面404

原因:Vue Router用history模式,Nginx未配置try_files,导致刷新时找不到静态资源。
解决:修改Nginx配置,在location /块中加入:

location / { root /usr/share/nginx/html; index index.html index.htm; try_files $uri $uri/ /index.html; # 关键:所有路径 fallback 到 index.html }

5. 答辩验收实战:用三张表、两个截图、一次操作征服评委

答辩不是讲PPT,而是现场演示“系统能解决什么问题”。我带过的项目,评委最关注三点:流程是否闭环、数据是否可信、异常是否可控。下面给出一套可直接复用的答辩动线,耗时控制在8分钟内,附带评委可能追问的应答要点。

5.1 验收动线:用三张表证明系统可信

不要泛泛而谈“我们有权限控制”,而是打开数据库,现场查三张表,用数据说话:

表名查询语句答辩话术
sys_userSELECT id, username, real_name, id_card FROM sys_user WHERE id = 1001;“这是组织者账号,身份证号已脱敏存储,证明实名制落地。”
vote_recordSELECT user_id, option_ids, create_time FROM vote_record WHERE voting_id = 55 AND user_id = 1001;“这是该用户在第55号投票中的记录,option_ids是JSON数组[101,102],证明多选逻辑正确。”
voting_auditSELECT before_status, after_status, operator_id, operate_time FROM voting_audit WHERE voting_id = 55 ORDER BY operate_time DESC LIMIT 3;“这是第55号投票的状态变更日志,从DRAFT→PUBLISHED→VOTING,每步操作人、时间、前后状态全留痕。”

提示:提前准备一个测试数据库,里面预置好这三条典型数据。答辩时直接连Navicat或DBeaver,评委一看就懂,比讲十分钟原理管用。

5.2 两个必演截图:登录态穿透与审计日志溯源

评委大概率会问:“如果用户A冒充用户B投票,怎么发现?” 这时不讲理论,直接切屏:

  • 截图1:JWT Token解析
    打开https://jwt.io/,粘贴当前登录用户的token,展开payload,指出"orgId": 201, "roleId": 2字段,并说:“Token由服务端签发,前端无法伪造orgId和roleId,所有接口校验均基于此。”

  • 截图2:voting_audit表数据
    展示一条记录:before_status='VOTING',after_status='CLOSED',operator_id=1001,并说:“这是组织者1001在2024-05-20 14:30:22关闭投票的操作,若事后质疑结果,可据此追溯到具体人、具体时间、具体动作。”

这两个截图,一个证“不可伪造”,一个证“不可抵赖”,直击投票系统核心诉求。

5.3 一次关键操作演示:从创建到导出的端到端闭环

现场演示完整流程,但只聚焦一个最小闭环:创建投票→发起投票→用户投票→导出结果。重点突出三个“自动”:

  • 自动状态流转:创建后状态为DRAFT,点击“发布”按钮,状态自动变为PUBLISHED,再点“开启”,变为VOTING——演示状态机拦截非法跳转(如从DRAFT直点“关闭”,按钮置灰或弹窗提示);
  • 自动计票快照:投票开启后,等待1分钟(或手动触发定时任务),刷新结果页,看到“最新快照时间:2024-05-20 14:35:00”,数字稳定不跳变;
  • 自动结果导出:点击“导出Excel”,下载文件,用Excel打开,确认姓名、选项、得票数三列齐全,且手机号等长数字未变科学计数法。

提示:导出文件名必须含时间戳,如投票结果_55_202405201435.xls,证明结果是“某一时刻”的固化快照,而非实时计算。

5.4 评委高频追问应答清单(附真实话术)

评委问题应答要点(直接背)技术依据
“如果用户投完票想撤回,怎么支持?”“系统设计为不可撤回。因为投票本质是单向承诺行为,撤回会破坏结果公平性。若真需修改,由组织者后台删除该记录并手动备注原因,审计日志会记录此次删除操作。”vote_record表无is_deleted字段,删除走物理删除+审计日志
“怎么防止刷票?比如用脚本批量提交?”“我们做了三层防护:一是IP+设备指纹24小时限1次;二是同一用户ID在同次投票中仅允许1条记录;三是组织者可后台查看vote_device_fingerprint表,手动封禁异常指纹。”vote_device_fingerprint表设计+后台管理界面
“数据安全怎么保障?比如数据库被拖库”“密码用BCrypt加密存储;身份证号在数据库中AES加密(密钥存在配置中心);所有敏感操作(如导出、删除)均记录审计日志,且日志表与业务表分离。”sys_user.password字段BCrypt加密,candidate.id_card字段AES加密
“移动端适配怎么样?”“Vue2用flex布局+rem适配,测试覆盖iPhone 12、华为Mate 40、小米12主流机型。所有按钮尺寸≥44px,表单输入框高度≥40px,符合移动端触控规范。”postcss-pxtorem插件配置+真机测试报告

从那以后我每次带学生做投票系统,都会强制他们在答辩前完成三件事:① 用Navicat连上测试库,查三张表并截图;② 用jwt.io解析自己的token;③ 手动触发一次计票快照并导出Excel。这三步做完,答辩底气就足了——因为你知道,评委看到的不是代码,而是可验证的数据事实。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进