Vue微信投票项目源码解析:从OAuth2授权到接口防刷的完整技术方案
2026/9/16 13:01:05 网站建设 项目流程

简介:vue 微信投票.zip 是一套基于 Vue.js 前端与 Java 后端组合开发的微信投票系统项目源码,适合正在学习前后端分离开发、微信生态接入的初中级开发者作为练习与参考。压缩包共 39 个文件,以 vue、js、json 等源码文件为主,辅以 jpg/png 等界面素材和工程配置文件,整体仅 2.48MB,结构紧凑便于快速浏览。项目围绕投票场景展开,包含页面组件、工具函数、公共配置等内容,可帮读者理解 Vue 组件化开发思路、微信 JS-SDK 的集成方式,以及后端如何通过 API 支撑投票数据交互。已有 74 人学习浏览,说明该案例具有一定的参考价值。解压后对照源码可梳理出一套从页面展示到数据上报的完整投票流程,对熟悉项目启动、前后端联调与功能扩展均有实际帮助。

1. 拆开这个 vue 微信投票包后,先别急着改代码

一个标题带“vue 微信投票”的压缩包,解压后能同时看到VueVote-masteryarn.lockbabel.config.jsvue.config.jssrc下的componentsutils,这基本坐实了它是一套 Vue CLI 3+ 搭建的前端工程,不是纯静态页面。yarn.lock的存在说明依赖是 yarn 锁定的,babel.config.js意味着代码里大量使用了 ES6+ 和可能的 JSX/TS 语法,vue.config.js说明项目里做了自定义构建配置。这类“vue + 微信”的项目,最常见的技术组合就是 Vue 2.x 全家桶做微信内 H5 投票页,Java 后端提供接口和用户身份校验,数据库用 MySQL 这类关系型库。适合拿来改造成企业内部投票、活动拉票、问卷调查等场景,前提是你愿意花时间把微信 OAuth2 网页授权、OpenID 存库、接口防刷这三件事重新捋一遍——因为多数打包好的成熟项目会把这几块的逻辑写得很散,直接跑起来容易在微信环境里踩各种隐蔽的坑。

2. 从 yarn.lock 到 vue.config.js:前端骨架是怎么组织的

拿到源码先看入口,再顺着入口看组件和请求封装,最后看构建配置。这个顺序能快速理清一条数据流:用户在微信里打开页面,Vue 组件发起 HTTP 请求,axios 实例带着 token 走到后端,后端校验通过后返回投票结果,组件更新视图。

2.1 main.js 与 App.vue 里藏着路由和状态管理的拼接方式

src/main.js是前端启动入口,通常做的事是Vue.use(Router)Vue.use(Vuex)、挂载 axios 到原型链上。一个典型的入口文件长这样:

import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' import { request } from './utils/request' Vue.prototype.$http = request Vue.config.productionTip = false new Vue({ router, store, render: h => h(App) }).$mount('#app')

逻辑说明:Vue.prototype.$http把封装好的 axios 实例挂到所有组件上,组件内直接this.$http.get('/api/vote/options')就能发请求,不用每个组件都重复 import。routerstore分别注入路由与全局状态。这个挂载方式在 Vue 2 里很常见,Vue 3 里则改成app.config.globalProperties.$http = request,如果你的项目是 Vue 3,不要在main.js里照抄Vue.prototype,那会报Vue is not defined

App.vue是整个页面的根组件,通常只放<router-view />。但更重要的一点是,很多投票项目会在App.vue里做微信 JSSDK 的初始化,因为wx.config需要在整个页面加载时执行一次,且签名用的 URL 必须是当前页面的完整地址。这里常见的问题是签名 URL 和实际访问 URL 不一致,导致wx.ready不触发。

2.2 components 与 utils:投票组件和 axios 封装的边界

src/components目录一般按功能拆分:VotePanel.vue负责渲染投票选项和提交按钮,ResultChart.vue负责展示实时结果,UserLogin.vue负责处理微信授权登录态。每个组件只做一件事,通过 props 接收活动 ID、选项列表,通过 events 向父组件抛出投票结果。

src/utils目录下最重要的文件是request.js,即 axios 封装。投票类项目的请求封装至少要考虑三件事:自动附带 token、统一错误提示、超时设置。

import axios from 'axios' const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API, timeout: 10000 }) service.interceptors.request.use(config => { const token = localStorage.getItem('vote_token') if (token) { config.headers.Authorization = 'Bearer ' + token } return config }, error => Promise.reject(error)) service.interceptors.response.use( response => response.data, error => { if (error.response && error.response.status === 401) { window.location.href = '/#/login' } return Promise.reject(error) } ) export { service }

参数说明:VUE_APP_BASE_API是环境变量,在.env.development里设为/api,在.env.production里设为https://yourdomain.com/api,这样前后端联调时前端代码不用改。localStorage.getItem('vote_token')是读取登录后存储的 JWT,后端通过这个 token 识别用户。401 拦截跳转登录页这个逻辑在投票场景下特别重要——微信授权过期后 token 失效,接口会返回 401,此时要引导用户重新授权,而不是让请求静默失败。

2.3 vue.config.js 里必须配好的 devServer 代理与构建输出

vue.config.js是 Vue CLI 的自定义配置文件。投票系统开发阶段最头疼的是跨域——前端跑在localhost:8080,后端跑在localhost:8081,浏览器会拦截跨域请求。解决方式是在vue.config.js里配置 devServer 代理:

module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:8081', changeOrigin: true, pathRewrite: { '^/api': '' } } } }, productionSourceMap: false }

参数说明:proxy/api前缀的请求转发到http://localhost:8081changeOrigin让后端拿到的请求头是后端域名的,避免后端做一些域名白名单校验时误伤。pathRewrite/api去掉,这样后端接口路径不需要额外加前缀。productionSourceMap: false是打包时关闭 source map,减小产物体积,也能避免别人直接从源码地图里扒出你的后端接口地址。

提示:如果压缩包里没有vue.config.js,但存在package.json且里面有vue-cli-service依赖,这个文件的缺失会导致自定义端口和代理配置失效。

3. 微信身份与 OpenID:投票不记名的代价

微信投票系统里,OpenID 是每个用户在某个公众号下的唯一标识。投票必须保证一人一票,前端再怎么隐藏按钮都没用,后端一定要拿到 OpenID。这不是一个可选项,而是微信生态下的硬约束。

3.1 为什么必须走微信 OAuth2 网页授权

微信内打开的 H5 页面拿不到用户的手机号、姓名,只能通过 OAuth2 静默授权拿到用户在该公众号下的 OpenID。关键是,OpenID 是公众号维度的,同一个用户在两个不同公众号下的 OpenID 不一样,所以后端建表时要把appidopenid一起存。

流程是:用户访问投票页,前端检测到 URL 上没有 code 参数,就跳转到微信授权地址,微信重定向回来时带上 code,前端把 code 发到后端,后端拿 code 加appidappsecret换取 OpenID,然后签发 JWT 返回给前端。这段授权跳转的地址需要你在微信公众平台配置,网页授权域名必须和你部署的域名完全一致。

const appid = 'wx1234567890abcdef' const redirectUri = encodeURIComponent(window.location.href.split('#')[0]) const scope = 'snsapi_base' const state = 'vote_' + new Date().getTime() const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${appid}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&state=${state}#wechat_redirect` window.location.href = authUrl

逻辑说明:window.location.href.split('#')[0]是为了把 URL 里的 hash 路由部分(#/vote/123)去掉,因为微信签名和回调都要求不带 hash。scope=snsapi_base表示静默授权,用户无感;如果想拿用户头像昵称,要改成snsapi_userinfo,这会弹出授权确认页,在投票场景里通常不用。state参数是防 CSRF 的,后端在回调时要校验 state。

3.2 前端如何串联 code 与 openid

拿到授权回调的 code 后,前端立即把 code 发到后端换取 token:

// utils/auth.js export function handleWxCallback() { const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get('code') if (!code) { redirectToWxAuth() return } return service.post('/wechat/login', { code }) }

前端把 code 传过去,后端完成code + appid + appsecret的换 OpenID 请求,再查数据库判断该 OpenID 是否已存在,存在就发 token,不存在就插入一条用户记录再发 token。这一步如果做得不严谨,就会出现同一个微信用户投票后被强制要求登录、或者投完票无法查询结果的问题。

3.3 微信 JS-SDK 与 vue-router 的兼容性问题

投票页如果想要微信原生的分享能力、或者调用扫一扫,需要引入wx的 JS-SDK 并调用wx.config。这里有个和 vue-router 强相关的大坑:wx.config的签名参数里有一个jsapi_ticket,它和当前 URL 绑定。如果你的投票页是单页应用,用户先访问https://domain.com/#/vote/1,然后又跳到https://domain.com/#/vote/2,此时页面 URL 变了但并没有整页刷新,wx.config里的 URL 还是第一次的。签名的timestampnonceStrsignature是基于某个位置的 URL 算出来的,一旦路由参数变化,分享出去的链接可能签名失败。

解决方式通常是在路由切换后的afterEach钩子里重新请求后端拿签名。

4. Java 后端与 JWT:接口校验怎么做才不掉链子

关键词里带 JAVA,这个投票系统的后端大概率是 Spring Boot。Vue 前端只负责渲染页面和收集点击,真正的验票、计数、防刷全部要落在 Java 这一层。

4.1 Spring Boot 的投票接口骨架

一个标准的投票接口长这样:

@RestController @RequestMapping("/api/vote") public class VoteController { @Autowired private VoteService voteService; @PostMapping("/submit") public Result<VoteResult> submit(@RequestBody VoteRequest request, @RequestAttribute("openid") String openid) { VoteResult result = voteService.vote(request.getActivityId(), request.getOptionId(), openid); return Result.success(result); } }

参数说明:@RequestAttribute("openid")的值不是前端传的,而是由拦截器在解析 JWT 之后塞进请求属性里的。这样业务方法拿到的openid是后端自己解析出来的,前端伪造不了。Result<T>是统一返回体,通常包含codemessagedata三个字段,前端 axios 响应拦截器直接response.data拿到的就是它。

4.2 幂等与防重复:后端必须兜底

投票系统最容易被攻击的点就是重复提交。前端按钮可以禁用,但直接用 Postman 或者 curl 刷接口根本绕不过。JWT 拦截器只能保证请求合法,不能保证用户只投一次。所以后端 service 里必须做两步校验:先查vote_record表里是否已有该openid + activity_id的记录,有就抛业务异常;没有就插入记录并更新票数。这两个操作必须在同一个事务里,否则并发下会出现两个人同时查到没有记录、同时插入成功、票数却只加了一次的情况。

@Transactional(rollbackFor = Exception.class) public VoteResult vote(Long activityId, Long optionId, String openid) { int count = voteRecordMapper.countByOpenidAndActivity(openid, activityId); if (count > 0) { throw new BizException(ErrorCode.ALREADY_VOTED); } VoteRecord record = new VoteRecord(); record.setOpenid(openid); record.setActivityId(activityId); record.setOptionId(optionId); record.setCreateTime(new Date()); voteRecordMapper.insert(record); voteOptionMapper.increaseVoteCount(optionId); return new VoteResult(optionId); }

要点说明:@Transactional保证insertincreaseVoteCount要么都成功要么都失败。countByOpenidAndActivity的 SQL 里必须用联合条件查询,且vote_record表要建联合唯一索引uk_openid_activity(openid, activity_id),这样即使并发穿透到数据库层,唯一索引也会让第二条插入直接报错,事务回滚,不会出现脏数据。

4.3 状态码与错误约定

前后端联调时,错误码一定要固定。投票场景下常用的规范是:200成功,401未登录或 token 过期,403已投票不可重复投,404活动不存在或已结束,500服务端异常。前端 axios 拦截器按这个约定给用户提示,比后端返回一段英文堆栈给用户看要体面得多。

5. 数据库表设计与 Redis 计数:一人一票怎么落到存储层

不管前端写得多么花哨,投票系统的核心还是数据模型设计。表设计不合理,活动一上线就会出问题。

5.1 用户表、活动表、选项表与投票记录表

需要四张表来支撑投票业务:

CREATE TABLE `t_user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `openid` VARCHAR(64) NOT NULL COMMENT '微信OpenID', `appid` VARCHAR(64) NOT NULL COMMENT '公众号AppID', `nickname` VARCHAR(64) DEFAULT '', `avatar` VARCHAR(255) DEFAULT '', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_openid_appid` (`openid`, `appid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_vote_activity` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(128) NOT NULL, `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `status` TINYINT DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', KEY `idx_start_end` (`start_time`, `end_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_vote_option` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `name` VARCHAR(64) NOT NULL, `vote_count` INT DEFAULT 0, KEY `idx_activity` (`activity_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `t_vote_record` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `activity_id` BIGINT NOT NULL, `option_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY `uk_user_activity` (`user_id`, `activity_id`), KEY `idx_option` (`option_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计说明:t_useropenidappid的唯一索引是必须的。t_vote_record不直接存openid,而是存user_id,这样如果要查用户的历史投票记录,不需要每次都用openid关联,而且user_id是 8 字节的 Long,比 64 字节的字符串索引更省空间。uk_user_activity从数据库层面彻底堵死了同一个人对同一个活动投两次的可能。

5.2 事务边界与批量投票的口子

有些活动允许多选投票,前端提交的是一个选项optionId数组。此时vote接口里不能只处理单个optionId,要在@Transactional方法里循环插入多条t_vote_record,并且每条插入前都查一次是否已存在。更好的方案是把uk_user_activity的唯一索引去掉,改成uk_user_activity_option(user_id, activity_id, option_id),这样一个人对一个活动里的每个选项各能投一票,但同一选项不能重复投。这个取舍要看业务定义「一人一票」是指一个活动只能投一次,还是一个活动可以对多个候选人各投一次。

5.3 Redis 原子计数与异步回写

高并发投票场景下,直接UPDATE t_vote_option SET vote_count = vote_count + 1会出现行锁竞争,几百人同时投票就可能把 MySQL 打满。常见做法是把票数计数放到 Redis:

INCR vote:count:{optionId}

INCR是原子操作,Redis 单线程处理,不会出现并发覆盖。前端查询实时票数时直接读 Redis 的GET vote:count:{optionId},响应速度能到毫秒级。后台再起一个定时任务,每 5 分钟把 Redis 里的计数值批量回写到 MySQL,保证数据的最终一致性。这里的技巧是投票记录仍然先写 MySQL 的t_vote_record(因为要防重复),只有计数走 Redis,这样既不丢防重复约束,又扛得住瞬时流量。

6. 打包部署、微信签名与 401 拦截的实战排错

最后落地到部署和调试,这一步坑最多。开发环境跑得好好的,一打包上线全是问题,基本都集中在路由模式、代理配置、签名校验这三处。

6.1 打包后布局异常的根因:history 路由需要服务器配合

投票项目如果用了vue-routerhistory模式,打包后直接扔到 nginx 里,刷新页面会 404,因为 nginx 只在根路径下找到了index.html,而 Vue Router 的history模式依赖服务器把所有路由都重写到index.html。老项目常用 hash 模式,但微信内分享出去的链接带#号很丑,所以很多人会改成 history 模式,改了之后必须同步改 nginx 配置:

server { listen 80; server_name vote.example.com; root /usr/share/nginx/html; index index.html; location / { try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8081; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }

配置含义:try_files把不存在的路径全部落到index.html,Vue Router 接管路由后再渲染对应页面。location /api/把接口反向代理到 Java 服务,这样前端请求/api/vote/options时不再需要配置跨域 CORS,因为浏览器看到的是同域请求。

6.2 微信 JS-SDK 签名校验失败的固定排查顺序

签名无效在微信投票项目里出现频率极高,位置也极其隐蔽。

wx.config({ debug: true, appId: appid, timestamp: res.timestamp, nonceStr: res.nonceStr, signature: res.signature, jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData'] }) wx.ready(() => { console.log('签名验证通过') }) wx.error(err => { console.error('签名验证失败', err) })

测试时把debug: true打开,微信会弹出错误信息。常见的错误信息对应关系是:invalid signature表示签名和 URL 不匹配;invalid jsapi_ticket表示 ticket 获取失败,通常是appsecret错误或者 IP 白名单没加;permission denied表示jsApiList里的方法不在授权范围内。

排查顺序:第一,确认后端签名时用的 URL 是不带 hash 的window.location.href.split('#')[0];第二,确认 jsapi_ticket 有缓存机制,7200 秒有效,每次签名都重新去拿会导致微信接口频率超限;第三,确认部署域名和公众号后台配置的「JS安全域名」完全一致,http 和 https 也不能混。

6.3 token 过期与 401 的静默续期

投票场景下用户可能长时间停留页面,JWT 过期后下一次点击投票会收到 401。比较顺滑的做法不是在拦截器里直接跳登录页,而是用 refresh_token 静默刷新:

service.interceptors.response.use( response => response.data, async error => { const originalRequest = error.config if (error.response.status === 401 && !originalRequest._retry) { originalRequest._retry = true const refreshToken = localStorage.getItem('refresh_token') const res = await service.post('/wechat/refresh', { refreshToken }) localStorage.setItem('vote_token', res.data.token) originalRequest.headers.Authorization = 'Bearer ' + res.data.token return service(originalRequest) } return Promise.reject(error) } )

实现说明:originalRequest._retry = true是防止死循环重试。第一次 401 时带上 refresh_token 去换新 token,拿到后自动重放之前失败的请求,用户无感知。如果 refresh_token 也失效了,再跳转到微信授权页重新走 OAuth2。这样处理之后,投票过程中的授权中断概率会明显下降,用户也不会因为 token 过期而丢掉尚未提交的选票。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询