接手这个问卷调查系统之前,我首先花了一天时间把需求文档里所有出现"端"字的地方都圈了出来。这毛病是之前被坑出来的——有次我以为"三端"就是PC加H5加小程序,结果产品经理说的第三端是"数据大屏",最后多做了两周。所以这次看到"thinkphp+vue问卷调查系统的设计与实现三端",我的第一反应不是选框架,而是先把"端"的定义掰扯清楚。
这个系统最后落地的方案是:后端用 ThinkPHP 6 统一提供接口,前端管理端用 Vue 3 + Element Plus,答题端分 H5 和微信小程序两套界面,小程序基于 uni-app 实现(本质还是 Vue 那套语法)。三个前端共用一套 API,问卷从创建、发布、填报到统计汇总,整套闭环在同一份数据模型上跑通。下面这篇文章我就按实际开发顺序,把需求边界、数据建模、接口设计、三端联调以及上线后遇到的坑完整拆一遍,给准备做同类多端项目的同学一份能直接参考的作业。
1. 三端问卷系统到底在解决什么问题
1.1 先把"三端"逐个对号入座
同一个项目里,不同角色进入系统的入口是完全不一样的。问卷调查系统里最典型的分法是这样三端:
| 端 | 使用者 | 载体 | 核心动作 |
|---|---|---|---|
| PC 管理端 | 问卷管理员 | 电脑浏览器 | 创建问卷、编辑题目、发布/停用、查看统计 |
| H5 答题端 | 普通答题用户 | 手机浏览器、微信内置浏览器 | 填写问卷、提交答案 |
| 微信小程序端 | 普通答题用户 | 微信小程序 | 填写问卷、提交答案 |
这里有个容易被忽略的点:H5 答题端和 PC 管理端虽然都是浏览器打开,但前者是 C 端公网访问,后者是 B 端内网管理,两者的认证方式、页面布局、接口鉴权策略完全不同,必须拆成两套前端工程。小程序端则单独走微信的登录体系。三端共用一个后端,但每一端看到的接口子集不一样,这是整个系统设计的第一条主线。
1.2 最小可用版本先划边界
问卷系统看起来功能不多,但真要全做能拖死你。我见过太多项目死在"问卷编辑器支持拖拽"这种伪需求上。做 MVP 的时候,我给自己划了一条明确的边界线:
- 要做:单选、多选、填空、单项评分四种基础题型;问卷的创建、编辑、发布、停用、删除;答题人提交后实时统计;管理员按问卷查看回收量、各题分布。
- 不做(第一期):逻辑跳题、分页分步、问卷模板市场、自定义皮肤、题目拖拽排序(用上下移动按钮替代)、实时导出 Excel 的复杂格式。
这条边界很重要。因为三端同时开发,任何一个"高级功能"都会让三端的联调工作量翻三倍。比如逻辑跳题,PC 管理端要配跳转规则,H5 要维护跳题状态机,小程序还要复刻一套,光是测试用例就能写上百条。MVP 阶段果断砍掉,上线跑通后再迭代,这是做多端项目最划算的决策。
1.3 角色权限模型
这个系统只有两种角色:管理员和答题用户。管理员走管理端登录,答题用户不需要注册,通过问卷链接或小程序入口匿名参与。匿名参与听起来简单,但它带来一个连锁设计——后端如何识别"同一个答题人"。
我们最终采用方案是:H5 端在用户首次打开问卷时,后端生成一个 UUID 存入 localStorage,作为匿名身份标识;小程序端直接用微信 openid 作为身份标识。答题记录表里同时存 openid 和 client_token 两个字段,H5 答题走 client_token,小程序答题走 openid,互不干扰。这样既满足了匿名问卷的需求,又保住了"同一用户不能重复提交"这个最基础的数据校验前提。
2. 技术选型不是拍脑袋:ThinkPHP 加 Vue 的取舍逻辑
2.1 后端为什么押注 ThinkPHP
现在一说后端就是 Java 和 Go,但 ThinkPHP 在这种中小型业务系统里依然有不可替代的位置。我选它核心是三点:
第一,部署成本极低。一套 PHP 环境加上 Nginx 就能跑,服务器要求比 Java 系低一大截,小团队维护起来省心。问卷调查系统这种 QPS 不高的业务,PHP 完全扛得住。
第二,ThinkPHP 6 的架构比起前几代正规了很多。它把依赖注入、中间件、注解路由这些现代框架的东西都吸收进来了。比如跨域问题,在 ThinkPHP 6 里可以通过全局中间件统一处理,不用每个控制器写一遍;JWT 认证也可以做成中间件挂在路由分组上。
第三,开发效率确实是这几个选项里最高的。一个简单业务接口,Controller 里十几行代码就完了,对应 Java 可能要写实体类、Mapper、Service 一堆文件。做这种三端项目,后端接口数量不算少但每个都不复杂,ThinkPHP 的"单文件解决一个资源"风格非常合适。
2.2 前端统一 Vue 的真实收益
三端前端,如果管理端用 Vue、H5 用 React、小程序用原生,那就等于养三个团队。这个项目规模不值得这样搞。统一 Vue 的好处在于:
- 管理端用 Vue 3 + Element Plus,这套组合做后台管理系统,组件全、文档多、遇到问题随便一搜就有答案。
- H5 答题端用 Vue 3 + Vant,移动端组件库成熟,而且没有使用任何需要额外配置的私有特性。
- 小程序端用 uni-app,它的语法基于 Vue 2/3,可以复用 H5 端的大部分答题逻辑和样式思路。axios 换成 uni.request,路由从 vue-router 换成 uni.navigateTo,剩下的答题渲染、数据组装逻辑基本可以平移。
实际开发中,我们把答题页的核心逻辑抽成了一个公共模块,H5 和 uni-app 各自写一个薄薄的适配层就完成对接。公共模块管题目渲染、答案暂存、必填校验、提交前的数据组装;适配层只负责请求、跳转、提示这类跟平台绑定的动作。这就是统一技术栈最值钱的地方。
2.3 整体请求链路
整个系统只有一条数据链路,理解这一条就理解了全部:
PC管理端(8080) ──┐ H5答题端(5173) ──┼── HTTP/JSON ──▶ ThinkPHP API ──▶ MySQL + Redis 小程序端(微信) ──┘所有前端只通过 HTTP 接口与后端通信,后端只返回 JSON,不渲染任何页面。管理端接口带管理员 JWT,答题端接口带匿名 token 或 openid。Redis 在这里的用途有两个,一是缓存问卷详情,二是实现提交问卷的幂等控制。MySQL 存核心业务数据。这套结构的好处是每个端都可以独立开发、独立部署,只要接口约定不变,哪一端出了问题都不会殃及其他端。
3. 数据模型设计:问卷系统最容易翻车的地方
3.1 核心表结构与字段说明
问卷系统的数据模型比普通 CRUD 要绕,因为它是典型的"一对多嵌套"结构:问卷下有题目,题目下有选项,题目又有不同类型。如果表设计得不清晰,后面统计写起来会非常痛苦。
我最终拆成了五张核心表:
| 表名 | 关键字段 | 说明 |
|---|---|---|
| questionnaire | id, title, description, status, start_time, end_time, created_by, created_at | status: 0草稿/1发布/2停用 |
| question | id, questionnaire_id, title, type, required, sort_order, options_json | type: 1单选/2多选/3填空/4评分;options_json 存选项数组 |
| answer_record | id, questionnaire_id, client_token, openid, answer_data_json, submit_time | 一条记录对应一次有效提交 |
| admin | id, username, password_hash, status | 管理员账号 |
| survey_log | id, questionnaire_id, action, operator, created_at | 记录问卷发布/停用等操作,便于追溯 |
这里我特意没有把"选项"拆成独立的 option 表。原因很直接:问卷系统的题目选项,生命周期完全跟随题目,不存在被其他题目复用的可能,拆开反而增加查询复杂度。用 JSON 字段把选项存进 questions 表里,读取题目时一条 SQL 全部带上,写统计逻辑时一次遍历就能算清楚。后面第 3 节我会专门说这个取舍。
3.2 题目选项用 JSON 存储的取舍
项目初期有人建议按标准范式拆四张表:问卷表、题目表、选项表、答卷表。第四范式很标准,但你会发现实际使用中,管理员编辑问卷时要把题目和选项一起读出来回显到页面上,答题用户提交时又要把答案和选项对应上,统计时还要按选项聚合。每一次操作都是连表查询,写出来的 SQL 又长又难维护。
在 ThinkPHP 里,我们采用的是"读时拆分、存时合并"策略。题目表保留一个 options_json 字段,编辑时后端把 JSON 解析成数组返回前端,前端渲染完,提交时再把数组序列化回 JSON 存进去。答题记录表同理,answer_data_json 存的是题目ID到答案的映射,比如:
{ "12": "A", "13": ["B", "C"], "15": "这个系统很好用" }用 JSON 存储带来的最大便利是写统计接口时简单到令人发指:先查出问卷所有题目,再查出所有答案记录,在内存里逐条解析,就能算出每一题的选项分布和填空答案列表。这套逻辑在 ThinkPHP 里用 collection 的 map 方法就能快速搞定。
相应的代价是,你不能在数据库层对选项内容做 WHERE 查询。但问卷系统的查询场景几乎全部是"按问卷ID查题目""按问卷ID查提交记录",没有"找出所有选了A选项的人"这种需求,所以这个代价可以接受。
3.3 统计信息怎么算才不拖垮数据库
统计是问卷系统的核心功能,但也最容易把小服务器打崩。尤其是问卷发布后,答题数据一直在涨,如果每次打开统计页都实时 count 所有记录,数据量上来后接口会越来越慢。
我的方案是双轨制。问卷表里维护一个 answer_count 字段,发布状态的问卷每收到一条有效提交就 +1,这样管理端列表页显示"回收 N 份"时,只是查了 questionnaire 表的一个字段,零压力。真正复杂的各题选项分布统计,则设置一个定时任务「每十分钟」跑一次,把结果写入一个统计缓存表;管理端统计页默认读缓存,如果管理员点了"刷新数据"再实时计算一次。这个设计对于十万级以下的答卷数据完全够用,也避免了每次刷新页面都要全表扫描。
4. 后端 API 的鉴权设计与核心接口实现
4.1 两套完全不同的鉴权逻辑
三端共用后端,最怕的就是鉴权混在一起。我们把接口按端分成了三个分组,每组用不同的中间件。
管理员端用 JWT。管理员调用登录接口拿到 token,之后的请求在 Authorization 头里带上,后端中间件解析 token 并核对管理员ID和状态。JWT 选它是因为管理端接口的后端渲染日志、统计导出等操作是无状态的,不需要在 Session 里存东西。
答题端则更轻量。H5 端首次访问问卷时,后端会生成一个随机 UUID 作为 client_token 返回给前端,前端存在 localStorage,之后的提交请求带着这个 token。小程序端则是先走微信 wx.login 拿到 code,后端调微信接口换取 openid,再用 openid 当作身份标识。这两套身份逻辑看似不同,最后都落到了同一张 answer_record 表里,只靠提交来源字段区分。
4.2 核心接口清单
接口不多,但每个都要严谨。整理一下核心接口:
| 接口 | 方法 | 鉴权 | 说明 |
|---|---|---|---|
| /api/admin/login | POST | 无 | 管理员登录,返回 JWT |
| /api/admin/questionnaire | GET/POST | 管理员JWT | 问卷列表/创建 |
| /api/admin/questionnaire/{id} | GET/PUT/DELETE | 管理员JWT | 问卷详情/编辑/删除 |
| /api/admin/questionnaire/{id}/publish | POST | 管理员JWT | 发布/停用问卷 |
| /api/questionnaire/{id}/detail | GET | 匿名token | 答题端获取问卷及题目列表 |
| /api/answer/submit | POST | 匿名token/openid | 提交答卷 |
| /api/admin/statistics/{id} | GET | 管理员JWT | 获取统计结果 |
答题端的 detail 接口和管理端的编辑接口,虽然都返回问卷详情,但返回字段完全不同。答题端只返回该阶段的题目,选项里不携带正确答案之类的管理字段;管理端则返回全部题目及排序、启用状态等配置。所以这里特意拆成两个接口,避免前端自己过滤字段导致安全漏洞。
4.3 提交问卷的校验、幂等与防刷
提交接口是整个系统最核心的接口,也是上线后最容易出问题的点。我把它拆成四层校验:
第一层,参数格式校验。题目的 type 字段决定了提交的答案格式,单选必须是字符串,多选必须是数组,填空必须是字符串,格式不对直接返回 400。这一层在 ThinkPHP 的 Validate 类里完成。
第二层,问卷状态与时间校验。只有 status 为发布状态、且当前时间在 start_time 和 end_time 之间才允许提交。这里有个容易漏的坑——很多人只校验状态,忘了时间范围,结果问卷还没开始就能提交。
第三层,幂等校验。用 Redis 做一个原子操作:以 questionnaire_id + client_token(或 openid)作为 key,调用 setnx 设置一个 10 秒过期的标记。如果 setnx 返回 false,说明同一个用户在短时间内重复提交过,直接拒绝。这能挡住绝大部分双击按钮造成的重复数据。
第四层,必填与答案内容校验。遍历题目列表,检查必填题是否有答案;多选答案里的选项 ID 必须存在于选项数组里,防止有人构造请求提交不存在的选项。
贴上提交接口的核心伪代码思路:
public function submit(Request $request) { $data = $request->only(['questionnaire_id', 'answers']); // 1. 问卷状态校验 $questionnaire = Questionnaire::find($data['questionnaire_id']); if (!$questionnaire || $questionnaire->status != 1) { return json(['code' => 400, 'msg' => '问卷不可提交']); } if (time() < strtotime($questionnaire->start_time) || time() > strtotime($questionnaire->end_time)) { return json(['code' => 400, 'msg' => '不在可提交时间段内']); } // 2. 幂等校验(Redis setnx) $key = 'survey:submit:' . $data['questionnaire_id'] . ':' . $this->clientToken(); if (!Redis::setnx($key, 1, 10)) { return json(['code' => 400, 'msg' => '请勿重复提交']); } // 3. 逐题校验并组装答案 $questions = Question::where('questionnaire_id', $data['questionnaire_id'])->order('sort_order')->select(); $checkResult = $this->validateAnswers($questions, $data['answers']); if ($checkResult !== true) { return json(['code' => 400, 'msg' => $checkResult]); } // 4. 写入答卷并自增统计 $record = AnswerRecord::create([...]); Questionnaire::where('id', $data['questionnaire_id'])->inc('answer_count')->update(); return json(['code' => 200, 'msg' => '提交成功']); }这套逻辑上线后基本没出过岔子。后来数据量上来,发现 Redis 的 setnx 幂等标记偶尔会因为网络抖动没有自动释放,导致个别用户被误判重复提交,我就在 try-catch 的 finally 里显式删除 key,同时保留过期时间双重保险。
5. 三端前端的落地差异:同一套接口,三套姿势
5.1 PC 管理端:Vue 3 + Element Plus 的后台骨架
管理端是三个端里最标准、最容易做的。核心页面就四个:登录页、问卷列表页、问卷编辑页、统计页。
问卷编辑页是这个后台最复杂的地方。左侧是题目列表,右侧是题目配置面板,支持添加题目、删除题目、上下移动。题目类型切换时,配置面板会动态变化:单选/多选显示选项编辑区,填空显示"是否必填"开关,评分题显示分值上限。这里我用的是动态组件方式,每种题型对应一个子组件,切换 type 字段时自动渲染对应配置项。
统计页面用了 ECharts。单选和多选用饼图加横向柱状图展示选项分布,填空用列表展示全部答案。ECharts 的选项初始化放在 onMounted 里,数据从统计接口拿,图表配置和数据更新通过 watch 触发。这里我栽过一次,问卷数据更新后图表不刷新,是因为 ECharts 实例的 setOption 第二个参数没传 true,导致新旧数据 merge 而不是覆盖。改成myChart.setOption(option, true)后一切正常。
5.2 H5 答题端:移动优先的适配细节
H5 答题端的开发难度不在功能,而在适配。手机屏幕尺寸千差万别,题目类型多,稍不注意就出现按钮太小、滚动卡顿、键盘弹起遮挡输入框的问题。
基础方案是 rem 适配加 Vant 组件。Vant 自带的 Checkbox、Radio、Field 组件已经解决了大部分表单交互问题,但有几个场景必须手动处理。
第一个是长问卷的滚动性能。一个问卷可能有三四十题,如果全部一次性渲染,低端手机上会明显卡顿。一开始我图省事直接 v-for 全部渲染,真机测试时掉帧严重。后来改成按当前题目滚动位置分批渲染:只有进入视口附近的几道题才渲染完整 DOM,其余用占位高度撑住。再配合 CSScontent-visibility: auto,性能一下就上来了。
第二个是选择题选项排版。多选选项文字长了以后换行,复选框要对齐首行文字而不是垂直居中,这个用 Flex 布局的 align-items: flex-start 解决。细节虽小,但很多问卷系统就是栽在这种地方,答题人看着难受就不愿意填完。
第三个是微信内置浏览器的特殊问题。分享给好友打开的问卷链接,微信里没有 location 栏,用户中途想复制链接得长按二维码。所以我们每个问卷详情页底部都放了一个"分享"按钮,调用微信 JS-SDK 的 updateAppMessageShareData 设置分享标题和缩略图。这个配置不能偷懒,不然分享出去的卡片是一张默认灰图,点进来的人会有一种不信任感。
5.3 小程序端:uni-app 同构与原生能力取舍
小程序端选择 uni-app,最大的理由就是能和 H5 共用答题逻辑的代码。但 uni-app 不是银弹,有些地方必须要花心思。
编译差异是第一个坑。uni-app 的模板语法和标准 Vue 基本一致,但它的小程序端不支持在 template 里调用复杂方法,比如v-for里嵌套多层函数调用就是雷区。我们答题逻辑里有一个"根据题目类型返回对应组件"的渲染函数,在 H5 端随便用,编译到小程序端就出现"找不到组件"的诡异问题。最后老老实实改成<component :is="'question-' + item.type" />这种形式,组件名必须静态写在 options 里,不能依赖运行时函数运算。
第二个是登录链路。小程序不能直接用 localStorage,它用 uni.setStorageSync,这个倒还好。真正的差异化在微信登录:必须通过 wx.login 拿 code,再通过后端换 openid。这套流程在开发工具里模拟不了真机效果,我们专门准备了一台测试机反复验证,确认 code 换 openid 的接口调用时机一定要放在用户真正提交答卷前,而不是在 onLoad 阶段。因为用户如果只是打开看看不提交,白白让微信登录弹一次授权,转化率会难看很多。
第三个是分包。问卷列表页、答题页、结果页如果都塞进主包,首包体积很容易超过 2MB 的限制。我们把答题页拆成了分包,页面路径放到pagesAnswer/目录下,用户点击"开始答题"跳转时按需加载。这样首包只有列表和首页,体积控制在 1MB 左右,真机上冷启动快了很多。
6. 实测踩坑:从联调到上线最疼的几个问题
6.1 跨域配置:三个前端域名不一样
三端本地开发时域名完全不同:管理端 localhost:8080,H5 是 localhost:5173,小程序请求的是测试域名。后端不可能给每个域名单独开 CORS,所以统一处理方法是 ThinkPHP 中间件里动态读取 Origin 头,然后返回对应的跨域头。
这里有个细节:处理跨域时,Access-Control-Allow-Credentials一旦设为 true,Access-Control-Allow-Origin就不能是*,必须明确返回请求方的 Origin。我们因为之前统一配了*,管理端登录一直报错,排查了半小时最后发现是这个原因。如果你也用 axios 的 withCredentials 传 Cookie,一定要把动态 Origin 配置写对。
另外小程序端的域名没有跨域概念,但它要求后端接口必须是 HTTPS,而且域名要在小程序后台配置白名单。开发阶段我们用了内网穿透临时域名,结果微信那边对非备案域名校验很严格,动不动就报"request:fail url not in domain list"。建议从第一天就用正式备案域名做开发联调,别省这一下。
6.2 提交按钮的重复点击问题
问卷提交这个动作,看起来没风险,实际是并发事故的高发区。用户手一抖连点三下提交按钮,三次请求几乎同时打到后端。我们虽然做了 Redis setnx 幂等,但幂等标记和写入记录不是原子操作,极端情况下仍有多条记录。
处理方式是前端后端同时加锁。前端在拿到提交成功的回调前,把按钮置为 loading 且 disabled,这是第一道闸;后端在 Redis 幂等的基础上,给 answer_record 表增加一个联合唯一索引(questionnaire_id, client_token),这是最后的兜底。有唯一索引在,就算前面所有逻辑都漏了,数据库也会把重复数据拦下来。上线后我们查过日志,唯一索引确实拦过两次异常提交。
6.3 统计接口的数据不准与缓存策略
上线两周后,运营反馈某个问卷的统计数字和后台列表的回收数字对不上。查下来的原因很典型:列表页那个 answer_count 是提交时自增的,但有的题目是可选的,答题人提交但没选某道题,统计页那道单选题的分布加起来和总回收数自然不一致。这不是 bug,是统计口径的差异。
解决方式是把统计口径明确定义:列表页的回收数是"有效提交记录数",统计页的选项分布是"该题的作答人数"。单选和多选题按"选了某个选项的人数 / 该题作答人数"算比例,填空只展示答案文本。统计缓存刷新时,如果发现某题作答人数为零(比如新发布的问卷),前端要优雅降级,显示"暂无数据"而不是除零报错。这个口径说明我写进了接口文档备注里,后面运营再来问就直接甩文档。
6.4 富文本题目的 XSS 与内容安全
问卷的填空题,管理端理论上是内部人,但问卷系统一旦对外开放,谁都能填。如果填空题前端直接 v-html 渲染内容,答题人可以提交一段携带脚本的 HTML,其他人打开统计页查看答案时脚本就会执行,这就是存储型 XSS。
处理方案是:写入数据库前过滤 HTML 标签,只保留纯文本;展示到统计页时用 Vue 的插值表达式而不是 v-html。如果以后要支持填空题里带链接,也要用白名单方式正则过滤,只允许http(s)://开头的链接被解析成可点击的<a>标签。安全这个事,在三端系统里特别容易被忽略,因为大家都觉得"就是个问卷而已",但实际上只要有一个公网入口,它就是一个完整的安全攻击面。
7. 回顾:这套方案值不值得复用
整个项目做下来,我最深刻的体会是:三端系统的核心难点从来不在某个端本身,而在"端与端之间的约定"。接口字段、鉴权方式、统计口径、错误码语义,这四件事如果不在开发前用文档写死,联调阶段的效率会惨不忍睹。我们把这个项目跑通后,沉淀出了一份接口约定文档,里面甚至连"提交成功但问卷已停止"这种边缘情况的返回码都定义好了,后来接手新同学看文档半小时就能上手改需求。
如果让我再做一次问卷系统,我依然会选 ThinkPHP 加 Vue 这套组合。倒不是说它是最潮的技术栈,而是它在中小型业务里把"开发效率""维护成本""部署难度"三者平衡到了最适合团队现状的位置。三端共用接口,数据模型一次设计到位,前端逻辑通过公共模块复用,这套模式不仅适用于问卷,也适用于投票、报名、评测、考试这些同构的信息收集类系统。你把这个项目的骨架换成自己的业务字段,基本可以平移到下一个需求上。