ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue微信投票项目源码解析:从OAuth2授权到接口防刷的完整技术方案

Vue微信投票项目源码解析:从OAuth2授权到接口防刷的完整技术方案 简介vue 微信投票.zip 是一套基于 Vue.js 前端与 Java 后端组合开发的微信投票系统项目源码适合正在学习前后端分离开发、微信生态接入的初中级开发者作为练习与参考。压缩包共 39 个文件以 vue、js、json 等源码文件为主辅以 jpg/png 等界面素材和工程配置文件整体仅 2.48MB结构紧凑便于快速浏览。项目围绕投票场景展开包含页面组件、工具函数、公共配置等内容可帮读者理解 Vue 组件化开发思路、微信 JS-SDK 的集成方式以及后端如何通过 API 支撑投票数据交互。已有 74 人学习浏览说明该案例具有一定的参考价值。解压后对照源码可梳理出一套从页面展示到数据上报的完整投票流程对熟悉项目启动、前后端联调与功能扩展均有实际帮助。1. 拆开这个 vue 微信投票包后先别急着改代码一个标题带“vue 微信投票”的压缩包解压后能同时看到VueVote-master、yarn.lock、babel.config.js、vue.config.js、src下的components与utils这基本坐实了它是一套 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。router和store分别注入路由与全局状态。这个挂载方式在 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 不一样所以后端建表时要把appid和openid一起存。流程是用户访问投票页前端检测到 URL 上没有 code 参数就跳转到微信授权地址微信重定向回来时带上 code前端把 code 发到后端后端拿 code 加appid、appsecret换取 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_typecodescope${scope}state${state}#wechat_redirect window.location.href authUrl逻辑说明window.location.href.split(#)[0]是为了把 URL 里的 hash 路由部分#/vote/123去掉因为微信签名和回调都要求不带 hash。scopesnsapi_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 还是第一次的。签名的timestamp、nonceStr、signature是基于某个位置的 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 ResultVoteResult 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是后端自己解析出来的前端伪造不了。ResultT是统一返回体通常包含code、message、data三个字段前端 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保证insert和increaseVoteCount要么都成功要么都失败。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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;设计说明t_user里openid和appid的唯一索引是必须的。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 打满。常见做法是把票数计数放到 RedisINCR vote:count:{optionId}INCR是原子操作Redis 单线程处理不会出现并发覆盖。前端查询实时票数时直接读 Redis 的GET vote:count:{optionId}响应速度能到毫秒级。后台再起一个定时任务每 5 分钟把 Redis 里的计数值批量回写到 MySQL保证数据的最终一致性。这里的技巧是投票记录仍然先写 MySQL 的t_vote_record因为要防重复只有计数走 Redis这样既不丢防重复约束又扛得住瞬时流量。6. 打包部署、微信签名与 401 拦截的实战排错最后落地到部署和调试这一步坑最多。开发环境跑得好好的一打包上线全是问题基本都集中在路由模式、代理配置、签名校验这三处。6.1 打包后布局异常的根因history 路由需要服务器配合投票项目如果用了vue-router的history模式打包后直接扔到 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.htmlVue 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 过期而丢掉尚未提交的选票。本文还有配套的精品资源点击获取
返回列表