基于FastAdmin构建年会投票系统:从数据库设计到高并发实战 📅 发布时间:2026/9/13 8:24:45 👁 浏览次数: 简介基于fastadmin框架的年会投票系统设计源码适合需要快速搭建企业年会在线投票场景的PHP开发者与二次开发人员。系统覆盖投票管理、数据统计、后台配置等环节能够帮助企业简化投票流程、提升活动参与效率。压缩包共2000个文件大小约67.72MB包含1209个JavaScript脚本、102个PHP脚本、174个HTML页面、110个CSS样式、158个JSON配置及若干SQL、Shell等资源分别承担前端交互、后端逻辑、页面结构、样式布局与数据配置功能。目前已有95人浏览学习。下载后可直接获得完整fastadmin项目源码同时附带Markdown文档、静态资源与脚手架文件便于理解目录结构、进行自定义扩展或迁移部署适合具备一定PHP基础并希望借鉴后台管理框架实践经验的开发者参考。1. 年会投票系统用 FastAdmin 做后端先想清楚主矛盾在哪里年会投票在功能上几乎谈不上复杂一个活动、一组候选人、一张投票记录表加一个实时榜单。真正决定项目成败的是后台能不能让运营者在最后一分钟改动活动时间、批量导入候选人名单、在会场大屏上看到实时票数并把最终结果导成 Excel。FastAdmin 自带后台、RBAC、菜单权限与 CRUD 生成器正好压中“后台负担重、前台轻”这一类管理系统的关键链路。选它而不是从零写后台意味着权限分配、字段扩展、菜单调整都能继续吃框架红利。接下来的章节沿数据模型、后台落地、现场并发、前台输出四条线把一套基于 FastAdmin 的年会投票系统从设计源码到可复现实现完整过一遍。2. 年会投票的数据库设计activity、candidate、record 三张表的边界FastAdmin 的数据表默认带fa_前缀表名与模型名、控制器名通过命令参数对应。年会投票最少需要三张表活动表fa_vote_activity、候选人表fa_vote_candidate、投票记录表fa_vote_record。有人会把活动时间、投票选项塞进一个 JSON 字段短期内省事但后台列表筛选、时间判断、权限控制都要解析 JSON第二次复用就会后悔。三张表各自管好边界后面生成 CRUD、写并发控制、导出统计都顺。2.1 活动表字段怎么设status 状态机与时间戳CREATE TABLE fa_vote_activity ( id int(10) unsigned NOT NULL AUTO_INCREMENT, title varchar(120) NOT NULL DEFAULT COMMENT 活动标题, type tinyint(1) NOT NULL DEFAULT 1 COMMENT 投票类型1单选2多选3排名, max_votes tinyint(3) unsigned NOT NULL DEFAULT 1 COMMENT 最多可选人数, start_time int(10) unsigned NOT NULL DEFAULT 0 COMMENT 开始时间, end_time int(10) unsigned NOT NULL DEFAULT 0 COMMENT 结束时间, status tinyint(1) NOT NULL DEFAULT 0 COMMENT -1关闭0未开始1进行中2已结束, remark varchar(255) NOT NULL DEFAULT , createtime int(10) unsigned NOT NULL DEFAULT 0, updatetime int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT年会投票活动表;type字段决定前台投票组件渲染成 radio 还是 checkbox也决定max_votes是否生效。单选时max_votes1多选时设为 3、5、10 这类具体数字排名模式可以留作后续扩展。start_time和end_time用 int 时间戳而不是 datetime是 FastAdmin 后台表单控件的默认习惯代码里直接和time()比较省去字符串转换。status用 -1、0、1、2 四个值做成状态机而不是简单布尔值。年会现场经常出现“原定 8 点结束、临时延到 8 点 10 分”的操作如果状态是布尔值必须靠后台改时间再改状态两处联动状态机加时间判断控制器里只需检查status 1 time() end_time。要注意 FastAdmin 的 CRUD 生成器会把status自动渲染成开关控件-1 这个值在开关控件上无法表达保存时还可能被强制转回 1。所以活动表后台列表里状态列应保持文本渲染状态转换交控制器处理。2.2 候选人表用 activity_id 支撑多轮投票复用CREATE TABLE fa_vote_candidate ( id int(10) unsigned NOT NULL AUTO_INCREMENT, activity_id int(10) unsigned NOT NULL DEFAULT 0 COMMENT 所属活动ID, name varchar(60) NOT NULL DEFAULT COMMENT 候选人姓名, dept varchar(60) NOT NULL DEFAULT COMMENT 部门/节目名, avatar varchar(255) NOT NULL DEFAULT COMMENT 照片, intro text COMMENT 简介, sort int(10) unsigned NOT NULL DEFAULT 0 COMMENT 排序, votes int(10) unsigned NOT NULL DEFAULT 0 COMMENT 票数缓存, status tinyint(1) NOT NULL DEFAULT 1 COMMENT 状态1显示0隐藏, createtime int(10) unsigned NOT NULL DEFAULT 0, updatetime int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_activity (activity_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT年会投票候选人表;activity_id是候选人表和活动表唯一的关联字段。年会通常有两轮投票一轮优秀员工、一轮节目人气。如果各建一张候选人表后台菜单和权限要重复配置共用一张候选人表后后台列表按activity_id下拉筛选即可两轮投票数据完全隔离。sort字段控制前台展示顺序运营希望谁排在前面就把 sort 调小。votes字段是票数缓存不是票数源头。真正可信的数据在投票记录表里votes只是为了避免每次大屏刷新都去COUNT(*)而设计的冗余。投票时在数据库事务里同时INSERT记录和UPDATE votes活动结束后可以跑一次重算任务校正。这个字段最大的问题是 FastAdmin CRUD 生成器会把它识别成可编辑整数后台列表直接渲染成输入框运营改数只要点一下保存。后面第 3 章会专门说怎么封掉这个入口。2.3 投票记录表的联合唯一索引把防重下沉到数据库CREATE TABLE fa_vote_record ( id int(10) unsigned NOT NULL AUTO_INCREMENT, activity_id int(10) unsigned NOT NULL DEFAULT 0 COMMENT 活动ID, candidate_id int(10) unsigned NOT NULL DEFAULT 0 COMMENT 候选人ID, user_id int(10) unsigned NOT NULL DEFAULT 0 COMMENT 员工ID未登录为0, openid varchar(64) NOT NULL DEFAULT COMMENT 设备token/第三方openid, ip varchar(46) NOT NULL DEFAULT COMMENT 客户端IP, createtime int(10) unsigned NOT NULL DEFAULT 0, PRIMARY KEY (id), KEY idx_activity_candidate (activity_id, candidate_id), UNIQUE KEY uk_user_vote (activity_id, user_id, openid, candidate_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT年会投票记录表;联合唯一索引uk_user_vote是防重复提交的第一道保险。activity_id user_id openid candidate_id的组合把“同一活动里同一设备对同一候选人的再次投票”直接挡在数据库层就算前端被绕过、重复 POST 进来第二次INSERT会抛主键冲突。openid字段语义上是设备指纹没有企业微信环境时放前端生成的 token有登录体系时放真实标识。多选场景下用户一次投 5 人请求参数是candidate_ids数组落到表里是 5 行记录。不要试图用逗号把候选人拼进单个字段那样候选人维度的COUNT、按活动导出明细、按候选人排序都会变成字符串处理。多行记录配合事务写入如果中途失败全部回滚不会留下投了一半的脏数据。2.4 用 CRUD 生成器快速拉起后台附字段渲染对照表表建好之后三张后台管理页不需要手写。在 FastAdmin 项目根目录执行php think crud -t vote_activity -c VoteActivity -u 1 php think crud -t vote_candidate -c VoteCandidate -u 1 php think crud -t vote_record -c VoteRecord -u 1参数说明-t指定表名FastAdmin 读取表结构生成字段配置-c指定控制器名生成结果落在application/admin/controller/下-u 1表示控制器、模型、视图、菜单权限节点一次性生成完。不加-u的话后续还要手动建视图和菜单年会项目工期紧不建议省这一步。执行后登录后台菜单栏里会出现“投票活动”三个管理页此时需要到权限管理里把菜单节点分配给对应管理组否则除超级管理员外的角色看不到。生成器对字段的渲染方式有四处和年会场景不符实测后建议按下面这张表调整字段CRUD 默认渲染年会场景处理status开关控件活动表状态改文本显示状态流转由控制器控制votes数字输入框只读或禁写票数只能由投票事务和对账任务更新type整数输入框改成下拉框填入单选/多选/排名三种文案createtime日期时间保留后台导出和列表排序都用它提示CRUD 生成器给出的字段配置在application/admin/library/traits/Backend.php里几乎万能但 generate 的是“常规管理系统”的交互。年会投票这种热点业务生成后必须手改add、edit两个方法的收参逻辑下一章直接演示怎么改。3. 基于 FastAdmin 的后台业务加固validate、状态拦截与语言包CRUD 生成器只解决基础增删改查。年会投票后台真正需要的是三类约束表单校验、活动状态流转、防运营误操作改票。这些约束如果只依赖前端 JS等于没有必须落到 FastAdmin 的 validate 机制和控制器方法里。3.1 手动补 validate 文件数字范围和日期顺序提前拦住FastAdmin 的 CRUD 命令默认不会生成 validate 文件。手动建一个application/common/validate/VoteActivity.php?php namespace app\common\validate; use think\Validate; class VoteActivity extends Validate { protected $rule [ title require|max:120, type in:1,2,3, max_votes require|between:1,20, start_time require|date, end_time require|date|after:start_time, ]; protected $message [ title.require 活动标题必填, max_votes.between 最多可选人数在1到20之间, end_time.after 结束时间必须晚于开始时间, ]; }校验规则里start_time和end_time用date是因为 FastAdmin 后台日期控件提交的是YYYY-MM-DD HH:mm:ss字符串。after:start_time这个参数会直接比较两个字符串日期比手动strtotime判断干净。max_votes.between限制在 1 到 20员工投票最多选 20 人已经足够防止运营误填 999 造成前台渲染异常。字符串日期入库前需要转时间戳最简单的方式是在模型里加修改器public function setStartTimeAttr($value) { return $value !is_numeric($value) ? strtotime($value) : $value; } public function setEndTimeAttr($value) { return $value !is_numeric($value) ? strtotime($value) : $value; }有了修改器控制器里不需要再写strtotimeadd和edit收到的时间字符串会自动转换。注意is_numeric判断是必要的因为编辑保存时 FastAdmin 会回填时间戳已经是 int 就不该再转。3.2 编辑和删除的边界活动结束、已有票数两道防线活动已结束或正在进行时运营不应该还能改动候选人基本信息。在候选人控制器里覆写editpublic function edit($ids null) { if ($this-request-isPost()) { $row $this-model-get($ids); if ($row $row[status] 2) { $this-error(活动已结束不能修改候选人); } $params $this-request-post(); unset($params[votes]); $this-request-post($params); } return parent::edit($ids); }unset($params[votes])是关键CRUD 生成器会把 votes 字段渲染进编辑表单运营提交时即使没改动也会把自己看到的票数原样写回。这里先取出全部 POST 参数、删掉 votes、再写回请求对象兜住这层风险。删除候选人同理public function del($ids null) { $count Db::name(vote_record)-whereIn(candidate_id, $ids)-count(); if ($count 0) { $this-error(已有投票记录的候选人不能删除); } return parent::del($ids); }whereIn是为了兼容多选删除后台表格勾选多个候选人时$ids是逗号分隔的字符串FastAdmin 的控制器方法会接收并展开。有票数记录的候选人删掉等于把历史投票的关联对象弄丢了导出 Excel 时会留下悬空记录所以直接禁止。3.3 FastAdmin 多语言源码里的语言包与权限分配细节FastAdmin 的后台语言包按控制器落在application/admin/lang/zh-cn/下。候选人表字段注释是“候选人姓名”但年会节目投票里的name其实是节目名dept其实是节目类型。不需要改表注释直接编辑application/admin/lang/zh-cn/vote_candidate.php里的标签映射即可。源码包里如果带有 en 或其他语言版本注意语言包文件必须和控制器名严格对应。控制器是VoteCandidate语言包就该是vote_candidate.php大小写错了 FastAdmin 会静默回落到默认文本页面看起来像“没改成功”。权限分配上运营人员的角色建议只勾选vote_activity/index、vote_candidate/index和导出相关权限不勾add/edit/del。年会投票的数据变动越少越好候选人名单通常由 HR 集中录完现场没有人再改后台。提示后台菜单生成了但管理组没勾权限打开页面会看到 404 或列表按钮消失。菜单权限节点执行php think menu可以按控制器注释重新同步同步后再去管理组里勾选。4. 年会现场投票的并发与防刷设计Redis 计数加 token 限流年会现场有两三百人同时扫码投票是很常见的事。对 MySQL 来说每秒几百条简单 INSERT 并不致命真正的风险点有两个一是用户手机卡顿后重复点击提交产生大量无效写二是大屏每隔两秒拉一次全候选人的票数直接把统计查询打到数据库上。4.1 先拆现场流量并发不在数据库写在重复提交和大屏查询投票动作拆开看是两条 SQL插入一条投票记录、更新候选人票数字段。即使 200 人同时提交乘以每人最多 5 票也就是每秒约 1000 次写入InnoDB 行锁能扛住。但 FastAdmin 前台默认没有针对重复提交的机制用户点一次提交卡两秒自然会再点一次于是同一秒产生两三个重复请求。联合唯一索引能挡住第二次写入可每次请求都先做查询、再报错浪费的是数据库连接和事务资源。大屏查询比投票写入更危险。大屏每两秒获取所有候选人的实时票数如果直接SELECT candidate_id, COUNT(*) ... GROUP BY一张 30 万行的记录表在高并发轮询下会把 InnoDB 的 buffer pool 打热现场大屏还会出现卡顿。常见做法是把计数放到 RedisMySQL 只承担落库职责。4.2 Redis 接入 FastAdmin缓存驱动配置与原子防重锁FastAdmin 的缓存配置在config/cache.php默认是 file 驱动。年会现场环境直接改默认驱动为 Redisdefault redis, stores [ redis [ type redis, host 127.0.0.1, port 6379, password , select 0, ], ]投票接口里用 Redis 做防重锁基于SETNX语义use think\facade\Cache; $redis Cache::store(redis)-handler(); $lockKey vote:lock: . $activityId . : . md5($token); if (!$redis-set($lockKey, 1, [NX, EX 15])) { $this-error(提交太频繁请稍后重试); }NX表示 key 不存在时才写入EX 15表示锁在 15 秒后自动过期。同一台设备在同一场活动内只能持有一把锁第二次重复提交会被直接拦截不再进入数据库。锁不需要手动删除15 秒覆盖一次完整的投票请求绰绰有余过期后用户还能重新投下一轮。4.3 token 生成机制与前端发送方式token 在投票页面加载时生成存到 localStorage作为设备指纹随每次投票请求提交let token localStorage.getItem(annual_vote_token); if (!token) { token t_ Date.now() _ Math.random().toString(36).slice(2, 10); localStorage.setItem(annual_vote_token, token); }token 的作用是防重不是防伪。同一个浏览器换一个 token 再投自然能绕过前端但后端还有联合唯一索引兜底同一个user_id下即使 token 变了也会被索引拦住。员工没有登录、user_id 0时token 就成了唯一标识配合 IP 字段可以做到基本的现场防刷。4.4 投票事务插记录、Redis 计数、回写 votes前端提交activity_id、candidate_ids、token三个参数后端的完整处理逻辑public function submit() { $activityId (int) $this-request-post(activity_id); $candidateIds $this-request-post(candidate_ids/a); $token trim($this-request-post(token)); $ip $this-request-ip(); if (!$candidateIds) { $this-error(请选择候选人); } $activity Db::name(vote_activity)-where(id, $activityId)-find(); if (!$activity || $activity[status] ! 1) { $this-error(活动未开放); } if (time() $activity[start_time] || time() $activity[end_time]) { $this-error(不在投票时间范围内); } if (count($candidateIds) $activity[max_votes]) { $this-error(最多可选 . $activity[max_votes] . 人); } $redis Cache::store(redis)-handler(); if (!$redis-set(vote:lock: . $activityId . : . md5($token), 1, [NX, EX 15])) { $this-error(请勿重复提交); } Db::startTrans(); try { foreach ($candidateIds as $candidateId) { Db::name(vote_record)-insert([ activity_id $activityId, candidate_id $candidateId, user_id 0, openid $token, ip $ip, createtime time(), ]); Db::name(vote_candidate)-where(id, $candidateId)-setInc(votes, 1); $redis-incr(vote:count: . $activityId . : . $candidateId); } Db::commit(); } catch (\Throwable $e) { Db::rollback(); $this-error(提交失败请重试); } $this-success(投票成功); }事务内的三条操作是绑定的插入记录、更新候选人票数、增加 Redis 计数。setInc(votes, 1)是 ThinkPHP 的原子自增返回受影响行数不需要先查后写。$redis-incr()为每个候选人维护一个独立的计数键大屏读取走这些键完全避开 MySQL。如果 Redis 和数据库中间某一步失败事务回滚Redis 计数可能出现比实际多 1 的偏差最后交给对账接口修正。5. 前台投票页与结果大屏API 输出、长轮询和对账收尾5.1 投票详情页和结果接口放前台 index 控制器投票页面放在application/index/controller/Vote.php区分页面渲染和 JSON 接口两类方法namespace app\index\controller; use think\Controller; use think\Db; use think\facade\Cache; class Vote extends Controller { public function detail() { $id (int) $this-request-get(id); $activity Db::name(vote_activity)-where(id, $id)-find(); if (!$activity || $activity[status] ! 1) { $this-error(活动不存在或未开放); } $candidates Db::name(vote_candidate) -where(activity_id, $id) -where(status, 1) -order(sort desc, id asc) -select(); $this-assign(activity, $activity); $this-assign(candidates, $candidates); return $this-fetch(); } public function result() { $activityId (int) $this-request-get(activity_id); $candidateIds Db::name(vote_candidate) -where(activity_id, $activityId) -column(id); $redis Cache::store(redis)-handler(); $data []; foreach ($candidateIds as $cid) { $data[$cid] (int) $redis-get(vote:count: . $activityId . : . $cid); } return json([code 1, data $data]); } }detail渲染页面result只输出 JSON。result 接口从 Redis 读计数Redis 挂了会返回空列表大屏上显示全为 0不会拖垮数据库。column(id)从候选人表取出所有 ID这里的查询在活动开始前就已经发生不在现场投票高峰期。5.2 大屏用 5 秒长轮询刷新不做 WebSocket年会大屏不需要毫秒级推送5 秒刷新一次足够。大屏页面里的 JavaScriptasync function refreshResult() { const res await fetch(/index/vote/result?activity_id activityId, { cache: no-store }); const json await res.json(); renderList(json.data); } setInterval(refreshResult, 5000); refreshResult();cache: no-store告诉浏览器不要缓存 JSON 响应否则大屏可能连续显示同一份数据。不用 WebSocket 的原因很简单FastAdmin 默认跑在 php-fpm 环境下没有常驻进程支持给一场年会单独部署 Swoole 或 Node 服务成本和稳定性风险都不划算。5 秒轮询一次200 人同时看QPS 也只有几十Redis 和 PHP 都扛得住。5.3 活动结束后的对账重新统计票数并校正 Redis 计数活动收尾时Redis 里的计数键可能因为崩溃、重启或中途回滚而和 MySQL 不一致。现场结束后跑一次对账以投票记录表为准重新生成票数public function recalc() { $activityId (int) $this-request-post(activity_id); $stats Db::name(vote_record) -where(activity_id, $activityId) -group(candidate_id) -field(candidate_id, COUNT(*) as total) -select(); foreach ($stats as $row) { Db::name(vote_candidate) -where(id, $row[candidate_id]) -update([votes $row[total]]); Cache::store(redis)-handler() -set(vote:count: . $activityId . : . $row[candidate_id], $row[total]); } $this-success(票数已重新统计); }这个接口可以做成后台按钮也可以直接命令行执行。对账完成后再导出 Excel给 HR 的就是一份以记录表为准、不受缓存干扰的最终数据。年会投票系统的收尾工作到此为止剩下的就交给大屏最后一次刷新把结果亮出来。本文还有配套的精品资源点击获取