ARTICLE DETAIL

资讯详情

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

ThinkPHP+Vue三端问卷系统设计与实践

ThinkPHP+Vue三端问卷系统设计与实践 接手这个问卷调查系统之前我首先花了一天时间把需求文档里所有出现端字的地方都圈了出来。这毛病是之前被坑出来的——有次我以为三端就是PC加H5加小程序结果产品经理说的第三端是数据大屏最后多做了两周。所以这次看到thinkphpvue问卷调查系统的设计与实现三端我的第一反应不是选框架而是先把端的定义掰扯清楚。这个系统最后落地的方案是后端用 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 要绕因为它是典型的一对多嵌套结构问卷下有题目题目下有选项题目又有不同类型。如果表设计得不清晰后面统计写起来会非常痛苦。我最终拆成了五张核心表表名关键字段说明questionnaireid, title, description, status, start_time, end_time, created_by, created_atstatus: 0草稿/1发布/2停用questionid, questionnaire_id, title, type, required, sort_order, options_jsontype: 1单选/2多选/3填空/4评分options_json 存选项数组answer_recordid, questionnaire_id, client_token, openid, answer_data_json, submit_time一条记录对应一次有效提交adminid, username, password_hash, status管理员账号survey_logid, 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/loginPOST无管理员登录返回 JWT/api/admin/questionnaireGET/POST管理员JWT问卷列表/创建/api/admin/questionnaire/{id}GET/PUT/DELETE管理员JWT问卷详情/编辑/删除/api/admin/questionnaire/{id}/publishPOST管理员JWT发布/停用问卷/api/questionnaire/{id}/detailGET匿名token答题端获取问卷及题目列表/api/answer/submitPOST匿名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 :isquestion- item.type /这种形式组件名必须静态写在 options 里不能依赖运行时函数运算。第二个是登录链路。小程序不能直接用 localStorage它用 uni.setStorageSync这个倒还好。真正的差异化在微信登录必须通过 wx.login 拿 code再通过后端换 openid。这套流程在开发工具里模拟不了真机效果我们专门准备了一台测试机反复验证确认 code 换 openid 的接口调用时机一定要放在用户真正提交答卷前而不是在 onLoad 阶段。因为用户如果只是打开看看不提交白白让微信登录弹一次授权转化率会难看很多。第三个是分包。问卷列表页、答题页、结果页如果都塞进主包首包体积很容易超过 2MB 的限制。我们把答题页拆成了分包页面路径放到pagesAnswer/目录下用户点击开始答题跳转时按需加载。这样首包只有列表和首页体积控制在 1MB 左右真机上冷启动快了很多。6. 实测踩坑从联调到上线最疼的几个问题6.1 跨域配置三个前端域名不一样三端本地开发时域名完全不同管理端 localhost:8080H5 是 localhost:5173小程序请求的是测试域名。后端不可能给每个域名单独开 CORS所以统一处理方法是 ThinkPHP 中间件里动态读取 Origin 头然后返回对应的跨域头。这里有个细节处理跨域时Access-Control-Allow-Credentials一旦设为 trueAccess-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 这套组合。倒不是说它是最潮的技术栈而是它在中小型业务里把开发效率维护成本部署难度三者平衡到了最适合团队现状的位置。三端共用接口数据模型一次设计到位前端逻辑通过公共模块复用这套模式不仅适用于问卷也适用于投票、报名、评测、考试这些同构的信息收集类系统。你把这个项目的骨架换成自己的业务字段基本可以平移到下一个需求上。
返回列表