ARTICLE DETAIL

资讯详情

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

Vue+Yii2在线投票系统:JSON字段存储、三表设计与IP去重实践

Vue+Yii2在线投票系统:JSON字段存储、三表设计与IP去重实践 简介基于Vue 2.0、element-UI、Yii 2.0与PHP开发的在线投票系统配有完整源码和项目文档适合毕业设计、课程设计及项目开发参考。系统采用前后端分离架构前台投票页面支持匿名投票与登录投票并依据用户IP限制重复投票后台管理端可注册登录支持创建、修改、激活、取消及完成投票主题同时可配置投票起止时间、单选或多选、多选最大数、最大投票数等核心参数。资源包共286个文件集成Vue组件、JavaScript脚本、PHP后端逻辑、SVG图标、SCSS样式、SQL数据库脚本和md说明文档压缩包仅1.25MB便于快速部署学习。数据库设计围绕用户、投票主题、投票记录三张表展开投票项以JSON对象存储逻辑清晰易于二次开发。项目源码已经严格测试现有87人学习下载适合扩展功能并用于实际开发。1. 这套 Vue Yii2 投票系统最值得拆的不是按钮而是那道 JSONvoting_system 把投票项、得票数和计时用的 key 全部塞进 theme 表的 options 字段里前端拿到的不是一行行关联查询结果而是一个能直接渲染成单选和多选组件的对象数组。设计上没有任何一个字段是“为了扩展性”而存在的每一张表都贴着“能跑完一次完整投票闭环”来建。对这种规模的系统来说与其用 option 子表加一堆 LEFT JOIN不如用 JSON 把结构拍平让毕业设计和课程设计都更好讲清楚。对正在找 Vue 2.0、element-UI、Yii 2.0 在线投票系统参考的人来说这份源码里公开控制器和非公开控制器的切分、IP 去重逻辑、前后端对 options 数组的处理方式是能直接落到自己工程里改的。2. 数据库三表设计把投票项和票数用 JSON 装进 theme 表2.1 user、theme、record 的建表姿势项目文档里写得很明确数据库只有 user、theme、record 三张表。面向前台投票的记录全部压在第一张表里后台管理、前台展示、投票历史都由这三张表承载。建表语句建议按下面的结构来写字段名可以按照自己的命名习惯调整但核心关系不要动CREATE TABLE user ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password_hash VARCHAR(255) NOT NULL, created_at INT NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE theme ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, title VARCHAR(255) NOT NULL, options JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1激活 2完成, start_time INT DEFAULT 0, end_time INT DEFAULT 0, is_multi TINYINT DEFAULT 0, max_select INT DEFAULT 1, max_votes INT DEFAULT 0 COMMENT 0表示不限制, user_id INT UNSIGNED DEFAULT 0 COMMENT 创建者, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE record ( id INT UNSIGNED NOT NULL AUTO_INCREMENT, theme_id INT UNSIGNED NOT NULL, user_id INT UNSIGNED DEFAULT NULL, user_ip VARCHAR(46) DEFAULT NULL, vote_time INT NOT NULL, PRIMARY KEY (id), KEY idx_theme_user (theme_id, user_id), KEY idx_theme_ip (theme_id, user_ip) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;三张表把业务边界切得很清楚user 只负责后台系统登录theme 负责保存投票主题的所有描述信息record 是投票行为的流水账。record 表里的 user_id 在匿名投票时直接置空用 user_ip 判断重复。登录投票时 user_id 有值user_ip 也保留下来这样即使一个 IP 下有多个人登录后台也能通过 user_id 区分同时也保留了 IP 维度的审计数据。这种设计对 Yii 2.0 来说很友好因为框架内置的 ActiveRecord 不需要为三张表之间复杂的关系写很多映射代码。对一个小型投票系统来说三张表已经是能维护的最小集合再多加一张选项表反而会让整个 CURD 流程变重。2.2 为什么把 options 设计成 JSON 而不是子表把投票项和票数存到 theme.options 这个 JSON 字段里最直接的好处是创建主题时不用同时往两张表写数据。前台上传一组选项后端只做一次 INSERT读主题详情也只要一次 SELECT返回给前端时整个投票界面就完整了。具体结构可以设计成关联数组的序列化形式以 key 作为选项的唯一标识number 是当前得票数{ 1: {label: Vue 2.0, number: 0}, 2: {label: React, number: 0}, 3: {label: Angular, number: 0} }key 不一定是自增数字也可以是前端生成的随机字符串。核心在于用户投票时提交的 new_options 数组里装的正是这些 key。后台判断时只需要检查 key 是否存在存在就给对应的 number 加 1。这个逻辑用 PHP 写非常直观因为 json_decode 之后的 options 本身就是关联数组。这里也能看出来theme 表天然就是热度信息的一部分。后台显示投票主题列表、前台展示投票进度都不需要去统计 record 表直接读 number 就可以。当投票需要修改票数或者重置时也只需要改 JSON方便调试。代价是如果要按选项维度做复杂的统计报表JSON 字段查起来会很别扭需要在 PHP 里取出全量数据再处理。但对于在线投票系统的核心场景这种设计足够。2.3 Yii 2.0 模型里读写 JSON 字段的写法Yii 2.0 的 ActiveRecord 对 JSON 字段不会自动做编解码需要自己处理。我一般会在模型里写一个 beforeSave 和 afterFind代码可以抽到一个公共 Trait 里复用class Theme extends \yii\db\ActiveRecord { public static function tableName() { return theme; } public function rules() { return [ [[title, options, user_id], required], [[options], safe], [[status, is_multi, max_select, max_votes], integer], ]; } public function afterFind() { parent::afterFind(); $this-options is_string($this-options) ? json_decode($this-options, true) : $this-options; } public function beforeSave($insert) { if (is_array($this-options)) { $this-options json_encode($this-options, JSON_UNESCAPED_UNICODE); } return parent::beforeSave($insert); } }afterFind 里面把字符串转成数组这样控制器里拿到 $theme-options 时可以直接遍历beforeSave 里把数组转回字符串避免把数组写入 JSON 字段时报错。注意在 Yii 2.0 的验证规则里需要把 options 标记为 safe否则批量赋值的时候数据会被过滤掉。如果项目中使用 MySQL 5.7 以上版本JSON 字段本身有合法性校验如果使用旧版本只能把字段类型定义为 TEXT在 PHP 层保证存入的是合法 JSON。这也是为什么很多人拿到这套源码后在本地部署时经常遇到 options 字段写不进去或者读出来是 null大多是没走模型直接用 DAO 操作了字段。3. Vue 2.0 element-UI 前端从解析选项到提交选票的完整链路3.1 前台投票页把 theme.options 转成可直接渲染的对象数组前台投票页面在 vote_web 目录里入口页面通常会请求主题接口拿到 theme 对象后第一步就是把 options 从 JSON 字符串解析成数组来渲染。如果后端已经通过模型处理过那前端拿到的是数组如果直接用 axios 调到原始 JSON 字符串就需要先做一次转换getTheme(themeId) { this.$axios.get(/api/theme/${themeId}).then(res { const theme res.data.data; this.theme theme; const options typeof theme.options string ? JSON.parse(theme.options) : theme.options; this.options Object.keys(options).map(key ({ key: key, label: options[key].label, number: options[key].number })); }); }这里用 Object.keys 把关联数组转成数组目的有两个。第一Vue 2.0 的响应式系统对直接给对象新增属性不敏感如果保留原始 JSON 对象结构后续修改某选项的 number 时很可能不是在初始化阶段就存在的字段导致视图不更新。转成数组后每个对象在渲染时就已经有 number 字段后续 this.options[i].number 的修改可以被 Vue 侦测到。第二转成数组方便用 element-UI 的 v-for 循环渲染 radio 和 checkbox 组件。拿到的 key 保留下来提交时只需把选中项映射成 key 数组前端不用维护一套额外 id后端也不用关心选项顺序。这就是整个投票系统前后端交互最核心的一次“数据对齐”。3.2 单选、多选与最大可选数的前端控制主题表里有 is_multi 和 max_select 字段。is_multi 为 0 时用 el-radio-group为 1 时用 el-checkbox-group。element-UI 的 checkbox-group 没有内置 max 属性所以最大可选数必须用 change 事件手动处理el-checkbox-group v-modelselectedOptions changehandleMultiChange el-checkbox v-foropt in options :labelopt.key :keyopt.key {{ opt.label }} /el-checkbox /el-checkbox-grouphandleMultiChange(value) { if (this.theme.is_multi value.length this.theme.max_select) { this.selectedOptions value.slice(-this.theme.max_select); } }这个截断逻辑把超出 max_select 的选项从数组尾部裁掉用户点击第 max_select 1 个选项时会发现之前勾选的最后一项被自动取消。处理方式不算优雅但胜在实现简单不需要额外记录点击顺序。要做得更好可以用 JavaScript 的 filter 函数配和时间戳记录被点击的顺序这里为了少写代码直接截断。单选模式相对简单el-radio-group 天然只有一个值提交的时候也要包一层数组submitVote() { const newOptions this.theme.is_multi ? this.selectedOptions : [this.selectedOptions]; this.$axios.post(/api/vote, { theme_id: this.theme.id, new_options: newOptions }); }后端要保证 new_options 里的元素都是字符串或数字并且与 options 里的 key 类型完全一致。最常见的坑是 JSON.parse 后 key 变成字符串而前端又把 new_options 拼成数字数组在 PHP 里使用 isset 判断时两边类型对不上导致投票无效。3.3 后台管理element-UI 可展开表格和动态选项编辑vote_support_web 是后台管理页面核心主题列表推荐用 element-UI 的展开行。这个场景正好用上“element-ui 表格里面的行可以收起来”的交互一行显示主题标题、状态、起止时间展开后显示当前选项和得票数。el-table :datathemeList v-loadingloading el-table-column typeexpand template slot-scopeprops el-table :dataprops.row.optionList sizemini el-table-column proplabel label选项/el-table-column el-table-column propnumber label票数/el-table-column /el-table /template /el-table-column el-table-column proptitle label投票主题/el-table-column el-table-column propstatus label状态/el-table-column /el-table创建主题页面用 el-form el-input 动态增删选项。每次点击“添加选项”就往数组里 push 一个新的对象点击删除就用 JavaScript 的 filter 过滤掉对应项提交前把选项数组转成后端需要的 JSONbuildPayload() { const options {}; this.optionList.forEach((item, index) { options[index 1] { label: item.label, number: 0 }; }); return { title: this.form.title, options: JSON.stringify(options), is_multi: this.form.is_multi, max_select: this.form.max_select, max_votes: this.form.max_votes, start_time: this.form.start_time, end_time: this.form.end_time }; }注意这里的 start_time 和 end_time 如果用 element-UI 的日期时间选择器默认返回 Date 对象提交前需要转成 Unix 时间戳和后端字段类型对齐。否则 Yii2 模型里的 integer 验证规则会直接拒绝请求。4. Yii2 控制器公开接口、非公开接口、IP 去重与投票判定4.1 公开控制器与后台控制器的访问控制切分摘要里提到的“非公开控制器和公开控制器”在 Yii 2.0 里通常对应一套独立的 Module 或者在控制器里设置 behaviors。前台投票页面不需要登录访问的是公开控制器后台管理系统需要登录访问的是非公开控制器。最简单的做法是把后台相关动作放在 AdminController 里用 accessControl 行为限制public function behaviors() { return [ access [ class AccessControl::class, only [create, update, list, delete], rules [ [ actions [create, update, list, delete], allow true, roles [], ], ], ], ]; }前台投票接口放在 VoteController 里不需要加 access 限制但要处理“登录用户可识别、匿名用户可投票”两种情况。在 action 内部使用 Yii::$app-user-isGuest 判断当前会话是否登录登录用户取 user_id未登录用户把 user_id 设为 null只记录 user_ip。这样既满足了前台免登录匿名投票的需求又给后台保留了用户维度的高级管理能力。4.2 投票接口new_options 与 options 的 key 比对投票接口是整个后端逻辑最核心的地方。根据项目正文用户在前台选择投票项后new_options 是一个数组后端判断其中所有 key 是否存在于主题的投票项数组对象 options 中。对应的 Yii2 action 可以这样写public function actionVote() { $post Yii::$app-request-post(); $themeId (int) $post[theme_id]; $newOptions $post[new_options]; $theme Theme::findOne($themeId); if ($theme null) { return $this-asJson([code 1, msg 主题不存在]); } $options $theme-options; foreach ($newOptions as $key) { if (isset($options[$key])) { $options[$key][number] (int) $options[$key][number] 1; } else { return $this-asJson([code 1, msg 包含无效选项]); } } $theme-options $options; $theme-save(); $record new Record(); $record-theme_id $themeId; $record-user_id Yii::$app-user-isGuest ? null : Yii::$app-user-id; $record-user_ip Yii::$app-request-userIP; $record-vote_time time(); $record-save(); return $this-asJson([code 0, msg 投票成功]); }这里有两个细节。第一foreach 循环里通过 isset 判断 key如果 options 里某个 key 对应的值是 nullisset 会返回 false因此更推荐用 array_key_exists。第二整个更新过程发生在内存里save 时会自动把模型里的数组编码成 JSON不会额外写业务代码。从逻辑上看这段代码能够保证普通场景下 new_options 中每个 key 都作用于 theme.options 中对应得票数。但并发情况下两个请求同时读到同一个 number各自加 1 后写入就可能出现票数少算。对毕业设计系统来说这个问题不一定暴露但后续如果投入实际使用应该考虑事务和行锁。4.3 beforeAction 里的 IP 去重、时间窗和状态校验在正式入库之前还需要做三重校验状态、时间、重复投票。状态和时间可以在 action 开头判断重复投票则要查 record 表。因为前台接口不限制登录而匿名用户和登录用户都可能投票所以查询条件是 theme_id user_id 或 theme_id user_ip 的组合。public function beforeAction($action) { if (!parent::beforeAction($action)) { return false; } $post Yii::$app-request-post(); $themeId (int) ($post[theme_id] ?? 0); $theme Theme::findOne($themeId); if ($theme null) { return false; } if ($theme-status ! 1) { Yii::$app-session-setFlash(error, 投票未激活); return false; } $now time(); if ($theme-start_time $now || ($theme-end_time 0 $theme-end_time $now)) { Yii::$app-session-setFlash(error, 不在投票时间内); return false; } if ($theme-max_votes 0) { $totalVoted Record::find() -where([theme_id $themeId]) -count(); if ($totalVoted $theme-max_votes) { Yii::$app-session-setFlash(error, 投票人数已满); return false; } } $condition [theme_id $themeId]; if (!Yii::$app-user-isGuest) { $condition[user_id] Yii::$app-user-id; } else { $condition[user_ip] Yii::$app-request-userIP; } $exists Record::find()-where($condition)-exists(); if ($exists) { Yii::$app-session-setFlash(error, 不可重复投票); return false; } return true; }beforeAction 中直接返回 false 会让整个 action 不执行同时也不会输出 JSON这种情况下前端拿到的响应会是 400 空响应。更合理的做法是根据请求类型判断如果是 AJAX 就直接输出 JSON 并且 exit。不过作为源码参考能看懂这里把校验逻辑前置了比写在 action 里堆一堆 if 更符合 Yii2 的控制流。IP 去重有几个边界要格外注意如果项目部署在 Nginx 后面直接用 Yii::$app-request-userIP 拿到的是 127.0.0.1必须配置 Nginx 把真实 IP 放在 X-Forwarded-For 头里Yii 2.0 还需要在配置文件中设置 trustedHosts否则 userIP 永远不可靠。很多人在本地测试很正常部署到服务器后突然无法投票就是这个原因。5. 部署与调试Nginx 重写、CORS 与重复投票拦截验证5.1 构建产物与 Yii2 入口怎么共存前端两个项目 vote_web 和 vote_support_web 分别构建后通常会把静态文件放到 Yii2 项目的 web 目录下与 index.php 同级。Yii2 的入口文件负责解析 PHP 路由但 Vue 打包出来的 JS/CSS 需要 Nginx 优先处理不能全部交给 PHP。一份可用的 Nginx 配置可以这样写server { listen 80; server_name vote.local; root /var/www/voting_system/vote/web; index index.php; location / { try_files $uri $uri/ /index.php?r$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; } location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { expires 7d; } }try_files 的作用是如果 Vue 路由使用了 history 模式访问/user/list时没有对应文件会回退到 index.phpYii 2.0 再根据 query string 里的 r 参数解析路由。如果前端只使用 hash 模式这一步可以简化但也会让 URL 丑一些。如果前后端分离部署前端服务器和后端 API 域名不一致容易出现 CORS 问题。最省事的办法是在 Yii2 的公开控制器里加一个行为统一添加跨域头public function behaviors() { return [ corsFilter [ class Cors::class, cors [ Origin [*], Access-Control-Request-Method [GET, POST, OPTIONS], ], ], ]; }但注意带 COOKIE 的登录态请求不能使用通配符 Origin必须写具体域名。这在使用 vote_support_web 后台时特别常见前后端端口不同session 又保存在 PHP 侧前端必须配置 axios 的 withCredentials 为 true否则登录接口成功响应但后续请求仍然提示未登录。5.2 用并发脚本验证 IP 去重和票数累加部署完成后最值得做的验证是同时发多个投票请求去撞重复拦截。下面这个 bash 脚本会从同一台机器发送 5 次投票请求理论上只有第一次能成功其余 4 次都被 IP 去重拦掉for i in {1..5} do curl -s -X POST http://vote.local/index.php?rvote/vote \ -H Content-Type: application/x-www-form-urlencoded \ -d theme_id1new_options[]1new_options[]3 \ -o /tmp/vote_resp_$i.json sleep 0.2 done cat /tmp/vote_resp_*.json如果 5 次请求全部返回成功说明 beforeAction 里的 IP 查询没有命中。这时要立刻检查 record 表里写入的 user_ip 是否正确。在本地命令行环境下userIP 是 127.0.0.1但 Nginx 转发后会变成真实内网 IPIP 判断逻辑必须与部署环境一致。如果是用同一浏览器操作登录用户重复投票和匿名用户重复投票是两套条件。登录用户判断 user_id匿名用户判断 user_ip所以用一个账号在同一个浏览器上重复投票拦截的是 user_id清除 Cookie 后再打同一 IP 的请求拦截的是 user_ip。这两条路径不能串。源码里 Record::find 的查询条件是根据 isGuest 二选一除非同时检查 user_id 和 user_ip否则同一个 IP 下注册多个账号可以绕过限制。要想加强控制就把 record 表里相同 theme_id user_ip 也做成唯一索引代码里同时查两个条件。测试时把 app.conf 里的日志级别调成 traceSQL 查询都会打到 runtime/logs 目录对照 Vue 端 Network 面板里的请求参数能很快定位是参数没传全还是后端校验提前拦了。真正调完这套投票链路回过来看 options 字段和 new_options 的关系其实整个系统并不深难的是把每一层的数据格式都保持在同一个预期里。本文还有配套的精品资源点击获取
返回列表