项目简介
校园投票系统是一个面向高校师生的在线投票平台,支持创建、管理和参与各类投票活动。本项目采用前后端分离架构,使用主流的Java全栈技术栈进行开发。
技术栈
后端
- Spring Boot 2.7.x
- Spring Security
- MyBatis Plus
- MySQL 8.0
- Redis
- JWT
前端
- Vue 3
- Element Plus
- Axios
- Vuex
- Vue Router
核心功能
1. 用户管理
- 学生/教师角色区分
- JWT token认证
- 权限控制
- 个人信息维护
2. 投票管理
- 创建投票
- 设置投票规则(单选/多选/截止时间等)
- 投票统计分析
- 实时结果展示
3. 数据可视化
- ECharts图表展示
- 投票结果分析
- 参与度统计
项目亮点
1. 高并发处理
@Service
public class VoteServiceImpl implements VoteService {@Autowiredprivate RedisTemplate redisTemplate;@Override@Transactionalpublic void submitVote(VoteDTO voteDTO) {// Redis计数器防止重复投票String key = "vote:" + voteDTO.getVoteId() + ":" + voteDTO.getUserId();if(redisTemplate.opsForValue().setIfAbsent(key, "1", 24, TimeUnit.HOURS)) {// 处理投票逻辑processVote(voteDTO);} else {throw new BusinessException("您已经投过票了");}}
}
2. 实时推送
@ServerEndpoint("/websocket/{userId}")
@Component
public class WebSocketServer {@OnMessagepublic void onMessage(String message, Session session) {// 处理投票实时更新JSONObject jsonObject = JSON.parseObject(message);String voteId = jsonObject.getString("voteId");// 广播投票结果broadcastVoteResult(voteId);}
}
3. 防作弊机制
@Aspect
@Component
public class VoteCheckAspect {@Before("execution(* com.vote.service.VoteService.submitVote(..))")public void checkVote(JoinPoint point) {// IP检测String ip = IpUtil.getIpAddr();// 时间间隔检测checkVoteInterval(ip);// 设备指纹检测checkDeviceFingerprint();}
}
项目架构
vote-system/
├── vote-admin/ // 后台管理
├── vote-api/ // 接口服务
│ ├── controller/
│ ├── service/
│ └── mapper/
├── vote-common/ // 公共模块
└── vote-web/ // 前端页面
性能优化
1. 缓存优化
- 使用Redis缓存热点数据
- 多级缓存架构
- 缓存预热
2. SQL优化
- 索引优化
- 分页查询优化
- 慢查询优化
3. JVM调优
- 内存分配优化
- GC参数调整
- 线程池配置
部署方案
1. 容器化部署
version: '3'
services:vote-api:image: vote-api:latestports:- "8080:8080"depends_on:- mysql- redismysql:image: mysql:8.0redis:image: redis:latest
2. 负载均衡
- Nginx反向代理
- 会话共享
- 静态资源CDN
项目总结
-
技术收获
- 深入理解Spring生态
- 掌握分布式系统开发
- 提升系统设计能力
-
经验教训
- 需求分析要充分
- 技术选型要谨慎
- 测试覆盖要全面
-
改进方向
- 微服务改造
- 性能持续优化
- 功能持续迭代
通过这个校园投票系统的开发,不仅实践了全栈开发技能,也积累了很多分布式系统开发和性能优化的经验。
校园投票系统 - 用户管理模块详解
一、数据库设计
1. 用户表设计
CREATE TABLE `sys_user` (`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',`username` varchar(50) NOT NULL COMMENT '用户名',`password` varchar(100) NOT NULL COMMENT '密码',`real_name` varchar(50) COMMENT '真实姓名',`role_id` int NOT NULL COMMENT '角色ID(1:学生 2:教师 3:管理员)',`student_id` varchar(20) COMMENT '学号',`teacher_id` varchar(20) COMMENT '教工号',`email` varchar(100) COMMENT '邮箱',`phone` varchar(11) COMMENT '手机号',`avatar` varchar(200) COMMENT '头像URL',`department` varchar(50) COMMENT '院系',`class_name` varchar(50) COMMENT '班级(学生)',`status` tinyint DEFAULT 1 COMMENT '状态(0:禁用 1:正常)',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
2. 角色权限表设计
CREATE TABLE `sys_role` (`id` int NOT NULL AUTO_INCREMENT,`role_name` varchar(50) NOT NULL COMMENT '角色名称',`role_code` varchar(50) NOT NULL COMMENT '角色编码',`description` varchar(100) COMMENT '描述',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';CREATE TABLE `sys_permission` (`id` int NOT NULL AUTO_INCREMENT,`name` varchar(50) NOT NULL COMMENT '权限名称',`code` varchar(50) NOT NULL COMMENT '权限编码',`url` varchar(100) COMMENT '请求URL',PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';CREATE TABLE `sys_role_permission` (`role_id` int NOT NULL,`permission_id` int NOT NULL,PRIMARY KEY (`role_id`,`permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
二、核心代码实现
1. JWT认证实现
@Component
public class JwtTokenProvider {@Value("${jwt.secret}")private String jwtSecret;@Value("${jwt.expiration}")private int jwtExpiration;// 生成Tokenpublic String generateToken(UserDetails userDetails) {Map<String, Object> claims = new HashMap<>();claims.put("username", userDetails.getUsername());claims.put("roles", userDetails.getAuthorities().stream().map(GrantedAuthority::getAuthority).collect(Collectors.toList()));return Jwts.builder().setClaims(claims).setIssuedAt(new Date()).setExpiration(new Date(System.currentTimeMillis() + jwtExpiration * 1000)).signWith(SignatureAlgorithm.HS512, jwtSecret).compact();}// 验证Tokenpublic boolean validateToken(String token) {try {Jwts.parser().setSigningKey(jwtSecret).parseClaimsJws(token);return true;} catch (Exception e) {return false;}}
}
2. 权限控制实现
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Autowiredprivate JwtTokenProvider jwtTokenProvider;@Overrideprotected void configure(HttpSecurity http) throws Exception {http.csrf().disable().sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and().authorizeRequests().antMatchers("/api/auth/**").permitAll().antMatchers("/api/student/**").hasRole("STUDENT").antMatchers("/api/teacher/**").hasRole("TEACHER").antMatchers("/api/admin/**").hasRole("ADMIN").anyRequest().authenticated();http.addFilterBefore(new JwtAuthenticationFilter(jwtTokenProvider),UsernamePasswordAuthenticationFilter.class);}
}// 权限注解使用示例
@RestController
@RequestMapping("/api/vote")
public class VoteController {@PreAuthorize("hasRole('TEACHER')")@PostMapping("/create")public Result createVote(@RequestBody VoteDTO voteDTO) {// 只有教师可以创建投票return voteService.createVote(voteDTO);}@PreAuthorize("hasAnyRole('STUDENT', 'TEACHER')")@PostMapping("/submit")public Result submitVote(@RequestBody VoteSubmitDTO submitDTO) {// 学生和教师都可以参与投票return voteService.submitVote(submitDTO);}
}
3. 用户信息维护
@Service
@Transactional
public class UserServiceImpl implements UserService {@Autowiredprivate UserMapper userMapper;@Autowiredprivate PasswordEncoder passwordEncoder;// 更新用户信息@Overridepublic void updateUserInfo(UserUpdateDTO userDTO) {SysUser user = new SysUser();BeanUtils.copyProperties(userDTO, user);// 敏感信息更新校验if (userDTO.getPhone() != null) {validatePhone(userDTO.getPhone());}if (userDTO.getEmail() != null) {validateEmail(userDTO.getEmail());}userMapper.updateById(user);}// 修改密码@Overridepublic void changePassword(PasswordChangeDTO passwordDTO) {// 验证旧密码SysUser user = userMapper.selectById(SecurityUtils.getCurrentUserId());if (!passwordEncoder.matches(passwordDTO.getOldPassword(), user.getPassword())) {throw new BusinessException("旧密码错误");}// 更新新密码user.setPassword(passwordEncoder.encode(passwordDTO.getNewPassword()));userMapper.updateById(user);}// 头像上传@Overridepublic String uploadAvatar(MultipartFile file) {// 文件格式校验validateImageFormat(file);// 上传到文件服务器或云存储String avatarUrl = fileService.uploadFile(file);// 更新用户头像SysUser user = new SysUser();user.setId(SecurityUtils.getCurrentUserId());user.setAvatar(avatarUrl);userMapper.updateById(user);return avatarUrl;}
}
4. 用户注册流程
@Service
public class AuthServiceImpl implements AuthService {@Autowiredprivate UserMapper userMapper;@Autowiredprivate PasswordEncoder passwordEncoder;@Autowiredprivate EmailService emailService;@Override@Transactionalpublic void register(RegisterDTO registerDTO) {// 1. 基础信息验证validateRegisterInfo(registerDTO);// 2. 角色特殊验证if (RoleEnum.STUDENT.getCode().equals(registerDTO.getRoleId())) {// 验证学号validateStudentId(registerDTO.getStudentId());} else if (RoleEnum.TEACHER.getCode().equals(registerDTO.getRoleId())) {// 验证教工号validateTeacherId(registerDTO.getTeacherId());}// 3. 发送邮箱验证码String verifyCode = generateVerifyCode();emailService.sendVerifyCode(registerDTO.getEmail(), verifyCode);// 4. 保存用户信息SysUser user = new SysUser();BeanUtils.copyProperties(registerDTO, user);user.setPassword(passwordEncoder.encode(registerDTO.getPassword()));userMapper.insert(user);}private void validateRegisterInfo(RegisterDTO registerDTO) {// 验证用户名是否存在if (userMapper.selectByUsername(registerDTO.getUsername()) != null) {throw new BusinessException("用户名已存在");}// 验证邮箱是否存在if (userMapper.selectByEmail(registerDTO.getEmail()) != null) {throw new BusinessException("邮箱已被使用");}// 密码强度校验if (!isPasswordValid(registerDTO.getPassword())) {throw new BusinessException("密码必须包含数字、字母和特殊字符,长度8-20位");}}
}
三、安全性考虑
1. 密码安全
@Configuration
public class PasswordConfig {@Beanpublic PasswordEncoder passwordEncoder() {// 使用BCrypt加密算法return new BCryptPasswordEncoder(12);}
}
2. 登录安全
@Component
public class LoginAttemptService {private LoadingCache<String, Integer> attemptsCache;public LoginAttemptService() {attemptsCache = CacheBuilder.newBuilder().expireAfterWrite(1, TimeUnit.DAYS).build(new CacheLoader<String, Integer>() {@Overridepublic Integer load(String key) {return 0;}});}public void loginFailed(String username) {int attempts = attemptsCache.getUnchecked(username) + 1;attemptsCache.put(username, attempts);}public boolean isBlocked(String username) {return attemptsCache.getUnchecked(username) >= 5;}
}
3. XSS防护
@Component
public class XssFilter implements Filter {@Overridepublic void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)throws IOException, ServletException {XssHttpServletRequestWrapper xssRequest = new XssHttpServletRequestWrapper((HttpServletRequest) request);chain.doFilter(xssRequest, response);}
}public class XssHttpServletRequestWrapper extends HttpServletRequestWrapper {@Overridepublic String[] getParameterValues(String parameter) {String[] values = super.getParameterValues(parameter);if (values == null) {return null;}int count = values.length;String[] encodedValues = new String[count];for (int i = 0; i < count; i++) {encodedValues[i] = cleanXSS(values[i]);}return encodedValues;}private String cleanXSS(String value) {// XSS清洗逻辑return HtmlUtils.htmlEscape(value);}
}
四、接口文档
1. 用户注册
POST /api/auth/register请求体:
{"username": "string","password": "string","realName": "string","roleId": 1,"studentId": "string","email": "string","phone": "string","department": "string","className": "string"
}响应:
{"code": 200,"message": "注册成功","data": null
}
2. 用户登录
POST /api/auth/login请求体:
{"username": "string","password": "string"
}响应:
{"code": 200,"message": "登录成功","data": {"token": "string","userInfo": {"id": "long","username": "string","realName": "string","roleId": 1,"avatar": "string"}}
}
通过以上详细设计和实现,我们构建了一个安全可靠的用户管理系统,为校园投票系统提供了坚实的用户基础。系统不仅支持基本的用户管理功能,还实现了完善的安全机制和权限控制。
校园投票系统 - 投票管理模块详解
一、数据库设计
1. 投票主表
CREATE TABLE `vote` (`id` bigint NOT NULL AUTO_INCREMENT,`title` varchar(100) NOT NULL COMMENT '投票标题',`description` text COMMENT '投票描述',`creator_id` bigint NOT NULL COMMENT '创建者ID',`vote_type` tinyint NOT NULL COMMENT '投票类型(1:单选 2:多选)',`max_choice` int DEFAULT NULL COMMENT '最多可选数量(多选时)',`start_time` datetime NOT NULL COMMENT '开始时间',`end_time` datetime NOT NULL COMMENT '结束时间',`status` tinyint DEFAULT 1 COMMENT '状态(0:草稿 1:进行中 2:已结束)',`is_anonymous` tinyint(1) DEFAULT 0 COMMENT '是否匿名投票',`is_public_result` tinyint(1) DEFAULT 1 COMMENT '是否公开结果',`target_type` tinyint COMMENT '投票对象(1:全体 2:教师 3:学生)',`department_limit` varchar(500) COMMENT '院系限制(JSON数组)',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),KEY `idx_creator` (`creator_id`),KEY `idx_status_time` (`status`, `start_time`, `end_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票主表';
2. 投票选项表
CREATE TABLE `vote_option` (`id` bigint NOT NULL AUTO_INCREMENT,`vote_id` bigint NOT NULL COMMENT '投票ID',`option_content` varchar(500) NOT NULL COMMENT '选项内容',`option_type` tinyint DEFAULT 1 COMMENT '选项类型(1:文本 2:图片)',`media_url` varchar(200) COMMENT '媒体URL',`sort_order` int DEFAULT 0 COMMENT '排序号',PRIMARY KEY (`id`),KEY `idx_vote` (`vote_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票选项表';
3. 投票记录表
CREATE TABLE `vote_record` (`id` bigint NOT NULL AUTO_INCREMENT,`vote_id` bigint NOT NULL COMMENT '投票ID',`user_id` bigint NOT NULL COMMENT '用户ID',`option_ids` varchar(500) NOT NULL COMMENT '选项ID(多选逗号分隔)',`ip_address` varchar(50) COMMENT 'IP地址',`device_info` varchar(200) COMMENT '设备信息',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,PRIMARY KEY (`id`),UNIQUE KEY `uk_vote_user` (`vote_id`,`user_id`),KEY `idx_vote_time` (`vote_id`,`create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票记录表';
二、核心代码实现
1. 创建投票
@Service
@Transactional
public class VoteServiceImpl implements VoteService {@Autowiredprivate VoteMapper voteMapper;@Autowiredprivate VoteOptionMapper optionMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Overridepublic Long createVote(VoteCreateDTO createDTO) {// 1. 参数校验validateVoteParams(createDTO);// 2. 保存投票信息Vote vote = new Vote();BeanUtils.copyProperties(createDTO, vote);vote.setCreatorId(SecurityUtils.getCurrentUserId());vote.setStatus(VoteStatusEnum.DRAFT.getCode());voteMapper.insert(vote);// 3. 保存投票选项List<VoteOption> options = createDTO.getOptions().stream().map(opt -> {VoteOption option = new VoteOption();option.setVoteId(vote.getId());option.setOptionContent(opt.getContent());option.setOptionType(opt.getType());option.setMediaUrl(opt.getMediaUrl());option.setSortOrder(opt.getSortOrder());return option;}).collect(Collectors.toList());optionMapper.insertBatch(options);// 4. 如果是立即发布,更新状态并初始化缓存if (createDTO.isPublishNow()) {vote.setStatus(VoteStatusEnum.ONGOING.getCode());voteMapper.updateById(vote);initVoteCache(vote.getId());}return vote.getId();}private void initVoteCache(Long voteId) {String voteKey = "vote:" + voteId;// 初始化选项计数器List<VoteOption> options = optionMapper.selectByVoteId(voteId);options.forEach(opt -> {redisTemplate.opsForHash().put(voteKey, "option:" + opt.getId(), 0);});// 设置过期时间Vote vote = voteMapper.selectById(voteId);long expireSeconds = (vote.getEndTime().getTime() - System.currentTimeMillis()) / 1000;redisTemplate.expire(voteKey, expireSeconds, TimeUnit.SECONDS);}
}
2. 投票规则设置与校验
@Component
public class VoteRuleChecker {@Autowiredprivate VoteRecordMapper recordMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;public void checkVotePermission(Long voteId, Long userId, List<Long> optionIds) {Vote vote = getVoteFromCache(voteId);// 1. 检查投票状态if (!VoteStatusEnum.ONGOING.getCode().equals(vote.getStatus())) {throw new BusinessException("投票已结束或未开始");}// 2. 检查投票时间LocalDateTime now = LocalDateTime.now();if (now.isBefore(vote.getStartTime()) || now.isAfter(vote.getEndTime())) {throw new BusinessException("不在投票时间范围内");}// 3. 检查投票对象限制checkTargetPermission(vote, userId);// 4. 检查选项数量if (VoteTypeEnum.SINGLE.getCode().equals(vote.getVoteType())) {if (optionIds.size() != 1) {throw new BusinessException("单选投票只能选择一个选项");}} else {if (optionIds.size() > vote.getMaxChoice()) {throw new BusinessException("超出最大可选数量");}}// 5. 检查是否重复投票String recordKey = "vote:record:" + voteId + ":" + userId;if (Boolean.TRUE.equals(redisTemplate.hasKey(recordKey))) {throw new BusinessException("您已参与过该投票");}}private void checkTargetPermission(Vote vote, Long userId) {SysUser user = SecurityUtils.getCurrentUser();// 检查院系限制if (StringUtils.isNotEmpty(vote.getDepartmentLimit())) {List<String> allowedDepts = JSON.parseArray(vote.getDepartmentLimit(), String.class);if (!allowedDepts.contains(user.getDepartment())) {throw new BusinessException("您所在院系不能参与此投票");}}// 检查身份限制if (VoteTargetEnum.TEACHER.getCode().equals(vote.getTargetType()) && !RoleEnum.TEACHER.getCode().equals(user.getRoleId())) {throw new BusinessException("仅教师可参与此投票");}if (VoteTargetEnum.STUDENT.getCode().equals(vote.getTargetType()) && !RoleEnum.STUDENT.getCode().equals(user.getRoleId())) {throw new BusinessException("仅学生可参与此投票");}}
}
3. 投票统计与实时更新
@Service
public class VoteStatisticsService {@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Autowiredprivate WebSocketServer webSocketServer;public void processVote(VoteRecord record) {String voteKey = "vote:" + record.getVoteId();// 1. 更新Redis计数器for (Long optionId : record.getOptionIds()) {redisTemplate.opsForHash().increment(voteKey, "option:" + optionId, 1);}// 2. 记录用户投票状态String recordKey = "vote:record:" + record.getVoteId() + ":" + record.getUserId();redisTemplate.opsForValue().set(recordKey, "1", 24, TimeUnit.HOURS);// 3. 推送实时结果broadcastVoteResult(record.getVoteId());}public VoteStatisticsDTO getVoteStatistics(Long voteId) {String voteKey = "vote:" + voteId;// 1. 获取选项得票数Map<Object, Object> optionCounts = redisTemplate.opsForHash().entries(voteKey);// 2. 计算总投票人数Long totalVoters = redisTemplate.opsForValue().size("vote:record:" + voteId + ":*");// 3. 构建统计结果VoteStatisticsDTO statistics = new VoteStatisticsDTO();statistics.setVoteId(voteId);statistics.setTotalVoters(totalVoters);List<OptionStatisticsDTO> optionStats = new ArrayList<>();optionCounts.forEach((k, v) -> {String optionId = ((String) k).split(":")[1];OptionStatisticsDTO stat = new OptionStatisticsDTO();stat.setOptionId(Long.valueOf(optionId));stat.setCount(((Integer) v).longValue());stat.setPercentage(calculatePercentage(((Integer) v).longValue(), totalVoters));optionStats.add(stat);});statistics.setOptionStatistics(optionStats);return statistics;}private void broadcastVoteResult(Long voteId) {VoteStatisticsDTO statistics = getVoteStatistics(voteId);webSocketServer.sendToAll(JSON.toJSONString(statistics));}
}
4. WebSocket实时推送
@ServerEndpoint("/websocket/vote/{voteId}")
@Component
public class WebSocketServer {private static final Map<String, Set<Session>> VOTE_SESSIONS = new ConcurrentHashMap<>();@OnOpenpublic void onOpen(Session session, @PathParam("voteId") String voteId) {VOTE_SESSIONS.computeIfAbsent(voteId, k -> new CopyOnWriteArraySet<>()).add(session);}@OnClosepublic void onClose(Session session, @PathParam("voteId") String voteId) {Set<Session> sessions = VOTE_SESSIONS.get(voteId);if (sessions != null) {sessions.remove(session);}}public void sendToVoteRoom(String voteId, String message) {Set<Session> sessions = VOTE_SESSIONS.get(voteId);if (sessions != null) {sessions.forEach(session -> {try {session.getBasicRemote().sendText(message);} catch (IOException e) {log.error("发送消息失败", e);}});}}
}
三、前端实现
1. 投票创建表单
<template><el-form :model="voteForm" :rules="rules" ref="voteForm" label-width="100px"><el-form-item label="投票标题" prop="title"><el-input v-model="voteForm.title" placeholder="请输入投票标题"></el-input></el-form-item><el-form-item label="投票说明" prop="description"><el-input type="textarea" v-model="voteForm.description"></el-input></el-form-item><el-form-item label="投票类型" prop="voteType"><el-radio-group v-model="voteForm.voteType"><el-radio :label="1">单选</el-radio><el-radio :label="2">多选</el-radio></el-radio-group></el-form-item><el-form-item label="最多可选" v-if="voteForm.voteType === 2" prop="maxChoice"><el-input-number v-model="voteForm.maxChoice" :min="1"></el-input-number></el-form-item><el-form-item label="投票选项" prop="options"><div v-for="(option, index) in voteForm.options" :key="index" class="option-item"><el-input v-model="option.content" placeholder="请输入选项内容"><template #append><el-button @click="removeOption(index)" icon="el-icon-delete"></el-button></template></el-input></div><el-button type="text" @click="addOption">添加选项</el-button></el-form-item><el-form-item label="时间设置"><el-date-pickerv-model="timeRange"type="datetimerange"range-separator="至"start-placeholder="开始时间"end-placeholder="结束时间"></el-date-picker></el-form-item><el-form-item><el-button type="primary" @click="submitForm">立即发布</el-button><el-button @click="saveAsDraft">保存草稿</el-button></el-form-item></el-form>
</template><script>
export default {data() {return {voteForm: {title: '',description: '',voteType: 1,maxChoice: 1,options: [{ content: '' }, { content: '' }],isAnonymous: false,isPublicResult: true,targetType: 1,departmentLimit: []},timeRange: [],rules: {title: [{ required: true, message: '请输入投票标题', trigger: 'blur' }],options: [{ required: true, message: '至少需要两个选项', trigger: 'blur' }]}}},methods: {addOption() {this.voteForm.options.push({ content: '' })},removeOption(index) {this.voteForm.options.splice(index, 1)},async submitForm() {try {await this.$refs.voteForm.validate()const [startTime, endTime] = this.timeRangeconst voteData = {...this.voteForm,startTime,endTime,publishNow: true}await this.createVote(voteData)this.$message.success('投票创建成功')} catch (error) {this.$message.error(error.message)}}}
}
</script>
2. 投票结果展示
<template><div class="vote-result"><div class="result-header"><h2>{{ voteInfo.title }}</h2><p>总投票人数:{{ statistics.totalVoters }}</p></div><div class="result-content"><div v-for="option in statistics.optionStatistics" :key="option.optionId" class="option-result"><div class="option-info"><span class="option-content">{{ option.content }}</span><span class="vote-count">{{ option.count }}票</span><span class="vote-percentage">{{ option.percentage }}%</span></div><el-progress :percentage="option.percentage":color="getProgressColor(option.percentage)"></el-progress></div></div><div class="result-chart"><div ref="pieChart" style="width: 100%; height: 400px;"></div></div></div>
</template><script>
import * as echarts from 'echarts'export default {data() {return {voteInfo: {},statistics: {totalVoters: 0,optionStatistics: []},ws: null,chart: null}},mounted() {this.initWebSocket()this.initChart()this.loadVoteData()},methods: {initWebSocket() {const voteId = this.$route.params.idthis.ws = new WebSocket(`ws://localhost:8080/websocket/vote/${voteId}`)this.ws.onmessage = (event) => {const data = JSON.parse(event.data)this.updateStatistics(data)}},initChart() {this.chart = echarts.init(this.$refs.pieChart)this.updateChart()},updateChart() {const option = {title: {text: '投票结果分布',left: 'center'},tooltip: {trigger: 'item',formatter: '{b}: {c} ({d}%)'},series: [{type: 'pie',radius: '60%',data: this.statistics.optionStatistics.map(opt => ({name: opt.content,value: opt.count})),emphasis: {itemStyle: {shadowBlur: 10,shadowOffsetX: 0,shadowColor: 'rgba(0, 0, 0, 0.5)'}}}]}this.chart.setOption(option)},updateStatistics(data) {this.statistics = datathis.updateChart()},getProgressColor(percentage) {if (percentage > 66) return '#67C23A'if (percentage > 33) return '#E6A23C'return '#F56C6C'}},beforeDestroy() {if (this.ws) {this.ws.close()}if (this.chart) {this.chart.dispose()}}
}
</script>
四、性能优化
1. Redis缓存优化
@Configuration
public class RedisConfig {@Beanpublic RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {RedisTemplate<String, Object> template = new RedisTemplate<>();template.setConnectionFactory(factory);// 使用 Jackson2JsonRedisSerializer 序列化Jackson2JsonRedisSerializer<Object> serializer = new Jackson2JsonRedisSerializer<>(Object.class);ObjectMapper mapper = new ObjectMapper();mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL);serializer.setObjectMapper(mapper);template.setValueSerializer(serializer);template.setHashValueSerializer(serializer);template.setKeySerializer(new StringRedisSerializer());template.setHashKeySerializer(new StringRedisSerializer());return template;}
}
2. 定时任务处理
@Component
public class VoteTaskScheduler {@Autowiredprivate VoteMapper voteMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;// 每分钟检查一次投票状态@Scheduled(cron = "0 * * * * ?")public void checkVoteStatus() {// 查找需要更新状态的投票List<Vote> votes = voteMapper.selectNeedUpdateStatus();for (Vote vote : votes) {// 更新投票状态if (LocalDateTime.now().isAfter(vote.getEndTime())) {vote.setStatus(VoteStatusEnum.ENDED.getCode());voteMapper.updateById(vote);// 持久化Redis中的投票数据persistVoteData(vote.getId());}}}private void persistVoteData(Long voteId) {String voteKey = "vote:" + voteId;Map<Object, Object> voteData = redisTemplate.opsForHash().entries(voteKey);// 批量保存投票统计数据List<VoteStatistics> statisticsList = new ArrayList<>();voteData.forEach((k, v) -> {String optionId = ((String) k).split(":")[1];VoteStatistics statistics = new VoteStatistics();statistics.setVoteId(voteId);statistics.setOptionId(Long.valueOf(optionId));statistics.setVoteCount(((Integer) v).longValue());statisticsList.add(statistics);});// 批量插入数据库if (!statisticsList.isEmpty()) {voteMapper.batchInsertStatistics(statisticsList);}}
}
校园投票系统 - 数据可视化模块详解
一、数据库设计
1. 投票统计表
CREATE TABLE `vote_statistics` (`id` bigint NOT NULL AUTO_INCREMENT,`vote_id` bigint NOT NULL COMMENT '投票ID',`total_voters` int NOT NULL DEFAULT 0 COMMENT '总投票人数',`student_count` int DEFAULT 0 COMMENT '学生参与数',`teacher_count` int DEFAULT 0 COMMENT '教师参与数',`department_stats` json COMMENT '院系参与统计',`hourly_stats` json COMMENT '按小时统计数据',`create_time` datetime DEFAULT CURRENT_TIMESTAMP,`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,PRIMARY KEY (`id`),KEY `idx_vote` (`vote_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票统计表';
2. 投票趋势表
CREATE TABLE `vote_trend` (`id` bigint NOT NULL AUTO_INCREMENT,`vote_id` bigint NOT NULL COMMENT '投票ID',`stat_time` datetime NOT NULL COMMENT '统计时间',`vote_count` int NOT NULL COMMENT '投票数',`cumulative_count` int NOT NULL COMMENT '累计投票数',PRIMARY KEY (`id`),KEY `idx_vote_time` (`vote_id`, `stat_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='投票趋势表';
二、核心代码实现
1. 数据统计服务
@Service
public class VoteAnalysisService {@Autowiredprivate VoteRecordMapper recordMapper;@Autowiredprivate VoteStatisticsMapper statisticsMapper;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;/*** 生成综合分析报告*/public VoteAnalysisDTO generateAnalysisReport(Long voteId) {VoteAnalysisDTO analysis = new VoteAnalysisDTO();// 1. 基础统计数据VoteStatistics stats = getBasicStatistics(voteId);analysis.setTotalVoters(stats.getTotalVoters());analysis.setStudentCount(stats.getStudentCount());analysis.setTeacherCount(stats.getTeacherCount());// 2. 院系参与度分析List<DepartmentStatDTO> deptStats = analyzeDepartmentParticipation(voteId);analysis.setDepartmentStats(deptStats);// 3. 时间趋势分析List<TrendPointDTO> trendData = analyzeVoteTrend(voteId);analysis.setTrendData(trendData);// 4. 用户画像分析UserPortraitDTO userPortrait = analyzeUserPortrait(voteId);analysis.setUserPortrait(userPortrait);return analysis;}/*** 分析院系参与度*/private List<DepartmentStatDTO> analyzeDepartmentParticipation(Long voteId) {return recordMapper.selectDepartmentStats(voteId).stream().map(record -> {DepartmentStatDTO dto = new DepartmentStatDTO();dto.setDepartment(record.getDepartment());dto.setParticipantCount(record.getCount());dto.setParticipationRate(calculateParticipationRate(record.getCount(), record.getTotalUsers()));return dto;}).collect(Collectors.toList());}/*** 分析投票时间趋势*/private List<TrendPointDTO> analyzeVoteTrend(Long voteId) {// 获取按小时统计的数据List<VoteTrend> trends = recordMapper.selectHourlyTrend(voteId);return trends.stream().map(trend -> {TrendPointDTO point = new TrendPointDTO();point.setTimestamp(trend.getStatTime());point.setCount(trend.getVoteCount());point.setCumulativeCount(trend.getCumulativeCount());return point;}).collect(Collectors.toList());}/*** 分析用户画像*/private UserPortraitDTO analyzeUserPortrait(Long voteId) {UserPortraitDTO portrait = new UserPortraitDTO();// 1. 身份分布Map<String, Integer> roleDistribution = recordMapper.selectRoleDistribution(voteId);portrait.setRoleDistribution(roleDistribution);// 2. 年级分布(学生)Map<String, Integer> gradeDistribution = recordMapper.selectGradeDistribution(voteId);portrait.setGradeDistribution(gradeDistribution);// 3. 活跃时段分析Map<Integer, Integer> hourlyActivity = recordMapper.selectHourlyActivity(voteId);portrait.setHourlyActivity(hourlyActivity);return portrait;}
}
2. 实时数据更新服务
@Service
public class RealTimeAnalysisService {@Autowiredprivate RedisTemplate<String, Object> redisTemplate;@Autowiredprivate WebSocketServer webSocketServer;/*** 实时更新统计数据*/public void updateRealTimeStats(VoteRecord record) {String voteKey = "vote:stats:" + record.getVoteId();// 1. 更新总投票数redisTemplate.opsForHash().increment(voteKey, "total", 1);// 2. 更新身份统计String roleKey = "role:" + record.getUserRole();redisTemplate.opsForHash().increment(voteKey, roleKey, 1);// 3. 更新院系统计String deptKey = "dept:" + record.getDepartment();redisTemplate.opsForHash().increment(voteKey, deptKey, 1);// 4. 更新小时统计String hourKey = "hour:" + LocalDateTime.now().getHour();redisTemplate.opsForHash().increment(voteKey, hourKey, 1);// 5. 推送实时数据更新broadcastStatsUpdate(record.getVoteId());}/*** 广播统计数据更新*/private void broadcastStatsUpdate(Long voteId) {RealTimeStatsDTO stats = getRealTimeStats(voteId);webSocketServer.sendToVoteRoom(voteId.toString(), JSON.toJSONString(stats));}/*** 获取实时统计数据*/public RealTimeStatsDTO getRealTimeStats(Long voteId) {String voteKey = "vote:stats:" + voteId;Map<Object, Object> rawStats = redisTemplate.opsForHash().entries(voteKey);RealTimeStatsDTO stats = new RealTimeStatsDTO();stats.setTotalVotes(((Integer) rawStats.getOrDefault("total", 0)));stats.setRoleStats(extractRoleStats(rawStats));stats.setDepartmentStats(extractDepartmentStats(rawStats));stats.setHourlyStats(extractHourlyStats(rawStats));return stats;}
}
三、前端图表实现
1. 投票结果分析组件
<template><div class="vote-analysis"><!-- 基础统计卡片 --><el-row :gutter="20" class="stat-cards"><el-col :span="6" v-for="(stat, index) in basicStats" :key="index"><el-card shadow="hover"><div class="stat-card-content"><div class="stat-value">{{ stat.value }}</div><div class="stat-label">{{ stat.label }}</div></div></el-card></el-col></el-row><!-- 投票趋势图 --><el-card class="chart-card"><div slot="header">投票趋势分析</div><div ref="trendChart" style="height: 400px"></div></el-card><!-- 院系参与度对比 --><el-card class="chart-card"><div slot="header">院系参与度分析</div><div ref="departmentChart" style="height: 400px"></div></el-card><!-- 用户画像分析 --><el-card class="chart-card"><div slot="header">用户画像分析</div><div class="portrait-charts"><div ref="roleChart" style="height: 300px; width: 50%"></div><div ref="gradeChart" style="height: 300px; width: 50%"></div></div></el-card></div>
</template><script>
import * as echarts from 'echarts'
import { formatDateTime } from '@/utils/date'export default {data() {return {basicStats: [{ label: '总投票数', value: 0 },{ label: '参与率', value: '0%' },{ label: '学生参与', value: 0 },{ label: '教师参与', value: 0 }],charts: {trend: null,department: null,role: null,grade: null}}},mounted() {this.initCharts()this.loadAnalysisData()},methods: {initCharts() {// 初始化趋势图this.charts.trend = echarts.init(this.$refs.trendChart)this.charts.trend.setOption({title: { text: '投票趋势' },tooltip: {trigger: 'axis',formatter: function(params) {const time = formatDateTime(params[0].data[0])return `${time}<br/>实时投票:${params[0].data[1]}<br/>累计投票:${params[1].data[1]}`}},xAxis: { type: 'time' },yAxis: { type: 'value' },series: [{name: '实时投票',type: 'bar',data: []},{name: '累计投票',type: 'line',data: []}]})// 初始化院系分析图this.charts.department = echarts.init(this.$refs.departmentChart)this.charts.department.setOption({title: { text: '院系参与度' },tooltip: {trigger: 'axis',axisPointer: { type: 'shadow' }},xAxis: {type: 'category',data: []},yAxis: [{type: 'value',name: '参与人数',position: 'left'},{type: 'value',name: '参与率',position: 'right',axisLabel: {formatter: '{value}%'}}],series: [{name: '参与人数',type: 'bar',data: []},{name: '参与率',type: 'line',yAxisIndex: 1,data: []}]})// 初始化用户画像图this.initPortraitCharts()},initPortraitCharts() {// 身份分布饼图this.charts.role = echarts.init(this.$refs.roleChart)this.charts.role.setOption({title: { text: '身份分布' },tooltip: {trigger: 'item',formatter: '{b}: {c} ({d}%)'},series: [{type: 'pie',radius: ['50%', '70%'],data: []}]})// 年级分布柱状图this.charts.grade = echarts.init(this.$refs.gradeChart)this.charts.grade.setOption({title: { text: '年级分布' },tooltip: {trigger: 'axis',axisPointer: { type: 'shadow' }},xAxis: {type: 'category',data: []},yAxis: {type: 'value'},series: [{type: 'bar',data: []}]})},async loadAnalysisData() {try {const voteId = this.$route.params.idconst data = await this.getVoteAnalysis(voteId)this.updateCharts(data)} catch (error) {this.$message.error('加载分析数据失败')}},updateCharts(data) {// 更新基础统计this.updateBasicStats(data)// 更新趋势图this.updateTrendChart(data.trendData)// 更新院系分析this.updateDepartmentChart(data.departmentStats)// 更新用户画像this.updatePortraitCharts(data.userPortrait)}}
}
</script><style scoped>
.vote-analysis {padding: 20px;
}.stat-cards {margin-bottom: 20px;
}.stat-card-content {text-align: center;
}.stat-value {font-size: 24px;font-weight: bold;color: #409EFF;
}.stat-label {margin-top: 10px;color: #666;
}.chart-card {margin-bottom: 20px;
}.portrait-charts {display: flex;justify-content: space-between;
}
</style>
2. 实时数据更新组件
<template><div class="real-time-stats"><el-card class="live-stats-card"><div slot="header"><span>实时统计</span><el-switchv-model="autoRefresh"active-text="自动刷新"@change="handleAutoRefreshChange"></el-switch></div><!-- 实时数据展示 --><div class="stats-grid"><div class="stat-item" v-for="(stat, index) in liveStats" :key="index"><div class="stat-value">{{ stat.value }}</div><div class="stat-label">{{ stat.label }}</div><div class="stat-trend" :class="stat.trend > 0 ? 'up' : 'down'" v-if="stat.trend">{{ Math.abs(stat.trend) }}%</div></div></div><!-- 实时图表 --><div ref="liveChart" style="height: 300px"></div></el-card></div>
</template><script>
import * as echarts from 'echarts'export default {data() {return {autoRefresh: true,refreshInterval: null,ws: null,liveStats: [{ label: '实时投票数', value: 0, trend: 0 },{ label: '当前参与率', value: '0%', trend: 0 },{ label: '最近一小时', value: 0, trend: 0 },{ label: '平均响应时间', value: '0s', trend: 0 }],liveChart: null}},mounted() {this.initWebSocket()this.initLiveChart()this.startAutoRefresh()},methods: {initWebSocket() {const voteId = this.$route.params.idthis.ws = new WebSocket(`ws://localhost:8080/websocket/vote/stats/${voteId}`)this.ws.onmessage = (event) => {const data = JSON.parse(event.data)this.updateLiveStats(data)this.updateLiveChart(data)}},initLiveChart() {this.liveChart = echarts.init(this.$refs.liveChart)this.liveChart.setOption({title: { text: '实时投票趋势' },tooltip: {trigger: 'axis',axisPointer: { type: 'line' }},xAxis: {type: 'time',splitLine: { show: false }},yAxis: {type: 'value',splitLine: { show: true }},series: [{name: '投票数',type: 'line',smooth: true,symbol: 'none',areaStyle: {opacity: 0.3},data: []}]})},updateLiveStats(data) {// 更新实时统计数据this.liveStats[0].value = data.totalVotesthis.liveStats[0].trend = data.voteTrendthis.liveStats[1].value = `${data.participationRate}%`this.liveStats[1].trend = data.rateTrendthis.liveStats[2].value = data.lastHourVotesthis.liveStats[2].trend = data.hourlyTrendthis.liveStats[3].value = `${data.avgResponseTime}s`this.liveStats[3].trend = data.responseTrend},updateLiveChart(data) {// 更新实时图表数据const series = this.liveChart.getOption().seriesseries[0].data.push([new Date().getTime(),data.totalVotes])// 保持最近30分钟的数据if (series[0].data.length > 180) {series[0].data.shift()}this.liveChart.setOption({ series })},handleAutoRefreshChange(value) {if (value) {this.startAutoRefresh()} else {this.stopAutoRefresh()}},startAutoRefresh() {this.refreshInterval = setInterval(() => {this.fetchLatestStats()}, 5000) // 每5秒刷新一次},stopAutoRefresh() {if (this.refreshInterval) {clearInterval(this.refreshInterval)}},async fetchLatestStats() {try {const voteId = this.$route.params.idconst data = await this.getVoteRealTimeStats(voteId)this.updateLiveStats(data)this.updateLiveChart(data)} catch (error) {console.error('获取实时数据失败:', error)}}},beforeDestroy() {this.stopAutoRefresh()if (this.ws) {this.ws.close()}if (this.liveChart) {this.liveChart.dispose()}}
}
</script><style scoped>
.live-stats-card {margin-bottom: 20px;
}.stats-grid {display: grid;grid-template-columns: repeat(4, 1fr);gap: 20px;margin-bottom: 20px;
}.stat-item {text-align: center;position: relative;
}.stat-value {font-size: 24px;font-weight: bold;color: #409EFF;
}.stat-label {margin-top: 5px;color: #666;
}.stat-trend {position: absolute;top: 0;right: 0;padding: 2px 6px;border-radius: 4px;font-size: 12px;
}.stat-trend.up {background-color: #f0f9eb;color: #67C23A;
}.stat-trend.down {background-color: #fef0f0;color: #F56C6C;
}
</style>
四、数据导出功能
1. Excel导出服务
@Service
public class VoteExportService {@Autowiredprivate VoteAnalysisService analysisService;/*** 导出投票分析报告*/public byte[] exportAnalysisReport(Long voteId) throws IOException {VoteAnalysisDTO analysis = analysisService.generateAnalysisReport(voteId);try (XSSFWorkbook workbook = new XSSFWorkbook()) {// 1. 创建基础统计sheetcreateBasicStatsSheet(workbook, analysis);// 2. 创建趋势分析sheetcreateTrendSheet(workbook, analysis.getTrendData());// 3. 创建院系分析sheetcreateDepartmentSheet(workbook, analysis.getDepartmentStats());// 4. 创建用户画像sheetcreatePortraitSheet(workbook, analysis.getUserPortrait());// 导出为字节数组try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {workbook.write(outputStream);return outputStream.toByteArray();}}}private void createBasicStatsSheet(XSSFWorkbook workbook, VoteAnalysisDTO analysis) {XSSFSheet sheet = workbook.createSheet("基础统计");// 创建标题样式XSSFCellStyle titleStyle = createTitleStyle(workbook);// 写入数据int rowNum = 0;Row row = sheet.createRow(rowNum++);Cell cell = row.createCell(0);cell.setCellValue("投票基础统计");cell.setCellStyle(titleStyle);// 写入统计数据createDataRow(sheet, rowNum++, "总投票人数", analysis.getTotalVoters());createDataRow(sheet, rowNum++, "学生参与人数", analysis.getStudentCount());createDataRow(sheet, rowNum++, "教师参与人数", analysis.getTeacherCount());createDataRow(sheet, rowNum++, "参与率", String.format("%.2f%%", analysis.getParticipationRate()));}private void createTrendSheet(XSSFWorkbook workbook, List<TrendPointDTO> trendData) {XSSFSheet sheet = workbook.createSheet("趋势分析");// 创建表头Row headerRow = sheet.createRow(0);headerRow.createCell(0).setCellValue("时间");headerRow.createCell(1).setCellValue("投票数");headerRow.createCell(2).setCellValue("累计投票数");// 写入数据int rowNum = 1;for (TrendPointDTO point : trendData) {Row row = sheet.createRow(rowNum++);row.createCell(0).setCellValue(point.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")));row.createCell(1).setCellValue(point.getCount());row.createCell(2).setCellValue(point.getCumulativeCount());}// 自动调整列宽for (int i = 0; i < 3; i++) {sheet.autoSizeColumn(i);}}
}
通过以上详细设计和实现,我们构建了一个功能完整的数据可视化模块,支持多维度的数据分析和展示,并提供了实时数据更新和数据导出功能。该模块不仅能帮助管理员了解投票的整体情况,还能为决策提供数据支持。