ARTICLE DETAIL

资讯详情

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

PHP+MySQL问卷系统:表结构设计与统计实现

PHP+MySQL问卷系统:表结构设计与统计实现 简介这是一份基于PHPMySQL打造的问卷调查系统完整毕设项目面向计算机相关专业在校生、毕业设计者及PHP初学者可用于课程设计、项目立项演示或二次开发。系统功能均已测试运行成功内含项目源码、SQL数据库文件与说明文档代码结构清晰模块划分明确便于快速理解问卷创建、发布、填写与统计的核心流程。资源共255个文件以200个PHP业务代码文件为主辅以17个配置文件与16个自定义函数文件同时包含数据库导入脚本、样式脚本和预览图等整体压缩包仅2.26MB轻量易部署。目前已有92人学习下载适合用来快速搭建原型或作为毕业设计的基础版本也可在此基础上扩展登录权限、数据导出等功能实战价值较高。1. 这份PHPMySQL问卷系统值得把表结构拆开看做问卷类系统很多人第一反应是把题目和选项塞进一个JSON字段里提交时整体存一条记录。表面看开发快等到要统计单选、多选、填空分别的分布时就会发现自己被困在字符串解析里最后只能用PHP一层层循环效率很难看。这套基于PHPMySQL实现的问卷调查系统代码量不大但把问卷、题目、选项、答卷拆成了四张核心表统计时一个GROUP BY就出结果。对做毕设、课程设计或者想弄明白PHP和MySQL怎么配合完成完整流程的人来说是非常合适的源码参考。建议拿到项目先别看样式直接打开数据库设计文档把表关系理清楚后面所有功能都会顺起来。2. 问卷系统的数据模型与MySQL表结构设计2.1 为什么要拆成问卷、题目、选项、答卷四张表问卷系统的核心不是页面而是数据模型。需要支持单选、多选、填空、矩阵题单一表结构根本无法覆盖。常见做法是将问卷本身存一张表题目独立成表每个选项独立成表用户每填一个选项就单独存一条答案。这样每道题有多少人选择哪个选项被选得最多都可以用一条带GROUP BY的SQL解决。如果省掉选项表把A/B/C用逗号拼在一个字段里统计时就不得不用 LIKE %A%索引失效不说多选题的边界还会画错。比如一份答卷同时选了A和C用LIKE %A%统计时又匹配了一次C的统计却会漏掉因为没有精确的“选C”记录。所以选项表必须单独存在才能保证多选的统计口径清晰。下面是我在同类项目里比较认可的表结构拆分表名职责关键字段questionnaire问卷基本信息id, title, status, created_atquestion问卷下的题目id, questionnaire_id, qtype, content, sortquestion_option每道题的选项id, question_id, content, sortanswer_record用户的答卷主记录id, questionnaire_id, user_identifier, created_atanswer_detail每道题的答题明细id, record_id, question_id, option_id, content这里多了一张 answer_detail 表与 answer_record 主记录分开。原因是每次提交问卷产生一条主记录但每道题可能对应多条明细尤其是多选题。分开后想统计回收份数就 COUNT(answer_record)想统计选项分布就 GROUP BY(answer_detail)互不干扰也不需要在取数时做额外的数组去重。2.2 建表SQL与字段约束要点下面这段SQL是一份可跑的基准结构去掉了业务冗余字段保留关键约束。实际下载的源码里可能还包含admin_user、survey_config之类的辅助表但核心是这五张。CREATE TABLE questionnaire ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, title VARCHAR(200) NOT NULL COMMENT 问卷标题, status TINYINT NOT NULL DEFAULT 0 COMMENT 0草稿 1发布 2结束, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE question ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, questionnaire_id INT UNSIGNED NOT NULL, qtype ENUM(radio,checkbox,text) NOT NULL, content VARCHAR(500) NOT NULL, sort INT NOT NULL DEFAULT 0, is_required TINYINT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE question_option ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, question_id INT UNSIGNED NOT NULL, content VARCHAR(255) NOT NULL, sort INT NOT NULL DEFAULT 0 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE answer_record ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, questionnaire_id INT UNSIGNED NOT NULL, user_identifier VARCHAR(64) DEFAULT NULL COMMENT IP或账号标识, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE answer_detail ( id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, record_id INT UNSIGNED NOT NULL, question_id INT UNSIGNED NOT NULL, option_id INT UNSIGNED DEFAULT NULL COMMENT 选项题必填, content VARCHAR(1000) DEFAULT NULL COMMENT 填空题内容 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有几个容易被新手忽略的点所有表都用 utf8mb4 字符集因为现实中用户可能在填空题里发一个表情符号latin1或utf8会直接报错或存成问号qtype用 ENUM 而不是 VARCHAR能把非法类型挡在数据库层option_id对填空题允许 NULL对选项题非空所以没有直接加 NOT NULL 约束。索引方面answer_detail表上的record_id、question_id是统计需求最常走的路径建议加上普通索引线上数据量上来后 GROUP BY 性能会明显不同。2.3 为什么不能用JSON字段存整份问卷有些快速开发框架喜欢把整份问卷序列化成JSON塞进一个字段答题时整包提取。原型阶段确实快后端不用拆表前端拿到JSON直接渲染。但等到要统计“第三题选B的百分比”时就必须把每一份答卷拿出来在PHP里json_decode再遍历题目数组。一万份答卷就是一万次完整JSON解析内存和耗时都压不住。更重要的是MySQL无法对JSON里嵌套字段做索引聚合所有统计都得回到PHP层做等于放弃了数据库最擅长的事情。作为毕设或课设用规范化表结构能体现数据库设计能力答辩时解释起来也更顺题目是动态追加的只需要向question和question_option两张表插记录不需要ALTER TABLE加列数据模型可以平滑扩展。3. 核心答题流程PHP端采集与入库实现3.1 问卷发布与题目渲染后台把问卷元数据配好后前端渲染整张问卷。常见做法是在PHP页面里按 “问卷 - 题目 - 选项” 三层遍历输出原生HTML表单。这个方案不依赖前端框架适合直接丢进Apache或Nginx跑也方便后续在页面里继续加JS逻辑。下面的片段演示从数据库取题目并输出的核心循环?php $pdo new PDO(mysql:host127.0.0.1;dbnamesurvey;charsetutf8mb4, root, password, [PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION]); $stmt $pdo-prepare( SELECT * FROM question WHERE questionnaire_id ? ORDER BY sort ); $stmt-execute([$questionnaireId]); $questions $stmt-fetchAll(PDO::FETCH_ASSOC); foreach ($questions as $q) { echo div classquestion; echo p . htmlspecialchars($q[content], ENT_QUOTES, UTF-8) . /p; if ($q[qtype] radio) { $optStmt $pdo-prepare( SELECT id, content FROM question_option WHERE question_id ? ORDER BY sort ); $optStmt-execute([$q[id]]); foreach ($optStmt-fetchAll(PDO::FETCH_ASSOC) as $opt) { echo labelinput typeradio nameq_ . $q[id] . value . $opt[id] . . htmlspecialchars($opt[content]) . /label; } } elseif ($q[qtype] text) { echo textarea nameq_ . $q[id] . /textarea; } echo /div; }这里的关键点是htmlspecialchars。问卷题目是运营或管理员录入的一旦里面出现script脚本直接输出就会被浏览器执行变成存储型XSS。转义后标签原样显示既不破版也不执行。选项ID直接作为value写入数据库时就用这个数字关联question_option表避免把文字内容存进答案表文字改了历史统计也不会乱。3.2 答卷提交的PHP处理与预处理语句问卷页提交到PHP后第一步要校验问卷是否存在且状态为发布第二步逐题读答案第三步把主记录和明细一起入库。这里我习惯把写入操作包在一个事务里这也是这套源码比很多简化版可靠的地方。一个问卷提交可能同时插入多条记录任何一条失败都应该全部回滚否则只存了主记录没有明细统计时会出现回收份数和选项总数对不上的情况。?php try { $pdo-beginTransaction(); $check $pdo-prepare( SELECT id FROM questionnaire WHERE id ? AND status 1 ); $check-execute([$questionnaireId]); if (!$check-fetch()) { throw new RuntimeException(问卷不存在或未发布); } // 插入主记录 $sqlRecord INSERT INTO answer_record (questionnaire_id, user_identifier, created_at) VALUES (?, ?, NOW()); $stmtRecord $pdo-prepare($sqlRecord); $stmtRecord-execute([$questionnaireId, $userIp]); $recordId $pdo-lastInsertId(); // 插入明细 $sqlDetail INSERT INTO answer_detail (record_id, question_id, option_id, content) VALUES (?, ?, ?, ?); $stmtDetail $pdo-prepare($sqlDetail); foreach ($questions as $q) { $field q_ . $q[id]; $value isset($_POST[$field]) ? $_POST[$field] : ; if ($q[is_required] $value ) { throw new RuntimeException(第 . $q[sort] . 题不能为空); } if (is_array($value)) { // 多选题提交的是选项ID数组 foreach ($value as $optId) { $stmtDetail-execute([$recordId, $q[id], (int)$optId, null]); } } else { $optionId ($q[qtype] text) ? null : (int)$value; $stmtDetail-execute([$recordId, $q[id], $optionId, $value]); } } $pdo-commit(); echo json_encode([code 0, msg 提交成功]); } catch (Exception $e) { $pdo-rollBack(); echo json_encode([code 1, msg $e-getMessage()]); }代码逻辑并不复杂但对新手来说有三个容易忽略的地方第一$pdo-lastInsertId()必须紧跟插入主记录的请求中间不能执行其他查询否则可能拿到错误的自增ID第二填空题虽然没有选项仍然要往answer_detail插一条content为文本的记录这样才能统一统计每道题的答卷数第三所有SQL都用预处理语句占位符避免用户提交的文本里带引号导致SQL语法错误。提交字段的入库规则可以整理成下表表单字段题目类型入库方式q_10 为字符串单选转换int后存option_idcontent为NULLq_10 为数组多选每个选项ID存一条明细q_10 为字符串填空option_id为NULLcontent存原文本这张表在调试时很有用。打开开发者工具看POST数据基本可以直接对照出哪道题存错了。3.3 参数校验与防重复提交在真实环境下前端点击不能代替后端校验。攻击者可以用curl直接构造POST跳过页面把q_10999这样的非法选项发进来。选项ID如果不做白名单校验统计时就会出现找不到对应选项的孤儿记录。常见做法是先从question_option表查出该题合法的option_id集合再用严格模式in_array过滤。$validOptions $optStmt-fetchAll(PDO::FETCH_COLUMN); if (!in_array((int)$value, $validOptions, true)) { throw new RuntimeException(非法选项); }in_array的第三个参数设为true是开启严格模式1和1不会被当成一回事。PHP的自动类型转换在比较外部输入时经常制造预料之外的真值所以从$_POST取值后统一用严格比较是成本最低的防呆设计。防重复提交也是问卷系统的常规需求。最轻量的做法是给answer_record表加唯一索引字段是(questionnaire_id, user_identifier)同一IP对同一份问卷只能提交一次。如果担心校内复用IP导致误杀可以把user_identifier换成用户登录ID未登录用户就生成一个带过期时间的匿名token。相比Redis加锁这种数据库唯一索引的做法对团队项目更友好索引冲突时捕获异常返回“您已提交过”即可。4. 统计看板用SQL聚合把问卷结果算出来4.1 单选、多选、填空的统计口径统计结果准确的前提是口径一致。单选题每份答卷最多一条明细多选题每份答卷可以有多条明细但统计“选项被选了多少次”的SQL完全可以复用因为明细表本身就表达了一次选择。填空则不同没有选项ID必须单独处理。题型统计目标关键SQL逻辑单选每个选项的票数COUNT(ad.id) GROUP BY option_id多选每个选项出现的次数同单选无需对record_id去重填空文本内容分布GROUP BY content过滤NULL和空字符串回收份数直接从主记录表算不要从明细表算。一份答卷可能有10道题从明细表COUNT出来的数字是题目数而不是问卷数。正确写法是对answer_record按问卷ID计数明细表只在统计单题时使用。4.2 统计查询SQL与PHP输出单选题统计是这道题里最典型的需求。下面SQL把选项表放左边LEFT JOIN答题明细再GROUP BY选项。这样就可以把没人选的选项也输出为0统计图不会缺柱子。SELECT qo.id, qo.content, COUNT(ad.id) AS cnt FROM question_option qo LEFT JOIN answer_detail ad ON ad.option_id qo.id AND ad.question_id qo.question_id WHERE qo.question_id 10 GROUP BY qo.id, qo.content ORDER BY qo.sort;注意这里的ON条件里同时约束了ad.question_id否则多选题的选项ID如果跨越题目存在相同ID会把别的题的答案也关联进来。虽然一般不会出现但加上这个条件是防御性编程的基本要求。PHP侧的封装是把结果集转成JSON方便前端图表组件直接使用?php $totalRecords $pdo-query( SELECT COUNT(*) FROM answer_record WHERE questionnaire_id 1 )-fetchColumn(); $sql SELECT qo.id AS opt_id, qo.content, COUNT(ad.id) AS cnt FROM question_option qo LEFT JOIN answer_detail ad ON ad.option_id qo.id AND ad.question_id qo.question_id WHERE qo.question_id ? GROUP BY qo.id, qo.content ORDER BY qo.sort; $stmt $pdo-prepare($sql); $stmt-execute([$questionId]); $rows $stmt-fetchAll(PDO::FETCH_ASSOC); $result [ question_id $questionId, total (int)$totalRecords, options array_map(function ($row) { return [ name $row[content], value (int)$row[cnt], ]; }, $rows), ]; header(Content-Type: application/json; charsetutf-8); echo json_encode($result);这段代码里的array_map把数据库行重组成前端友好的结构同时显式把字符串数字转成int。PDO从MySQL取出的COUNT结果在PHP中通常表现为字符串直接传给ECharts工具可能因为类型不匹配报警转成int后更稳定。total字段用于前端计算百分比比如“第三题选B占所有答题者的比例”。4.3 一个容易踩的坑NULL和空字符串填空题统计里最常见的bug是把NULL和空字符串混为一谈。用户如果没填写直接跳过$_POST里不会有这个字段后端写入明细时content可能是NULL如果用户填了但内容被trim成空那么写入的是空字符串。统计时如果没有同时过滤这两种状态结果里会出现一条看不见的“空白答案”计数。SELECT content, COUNT(*) AS cnt FROM answer_detail WHERE question_id 20 AND content IS NOT NULL AND content GROUP BY content ORDER BY cnt DESC;使用COUNT(content)会自动跳过NULL但空字符串不会跳过。所以最稳妥的过滤条件是保留content IS NOT NULL和content 两个判断。这个细节在本地测试只有几十条数据时看不出来一旦上到几千份答卷空白答案可能排到前几名报告就会失真。5. 排错与扩展从本地运行到线上部署的几个关键点5.1 PHP连接MySQL常见错误本地搭建这套系统最常遇到的报错是SQLSTATE[HY000] [2002] Connection refused。看到这个先别急着改代码用命令行确认MySQL服务是否真的在跑。Windows下可以在服务管理器里看MySQL服务状态Linux下用systemctl status mysql。如果服务正常再检查端口默认3306连接串里写错或者被防火墙挡了都会报这个错。另一个高频问题是字符集。DSN里不写charsetutf8mb4PHP和MySQL之间传输的字符集可能不一致中文变成问号表情符直接存不进去。$dsn mysql:host127.0.0.1;port3306;dbnamesurvey;charsetutf8mb4;用PDO扩展连接时这个DSN通常比执行SET NAMES更可靠。如果还在用老旧的mysql_*函数PHP 7.0以后已经彻底移除必须换成PDO或mysqli。很多旧项目源码跑不起来导火索就是这里。5.2 部署到宝塔/Nginx时需要注意的配置下载的源码如果之前是在本地Apache跑的放到宝塔Nginx环境时要检查几个点。首先是PHP版本建议用7.4或8.0以上配合pdo_mysql扩展其次是Nginx站点的伪静态规则如果系统里用到了URL重写需要把规则从Apache的.htaccess转成Nginx格式。问卷系统通常没有复杂路由直接访问PHP文件即可伪静态要求不高。检查项建议值PHP版本7.4或8.0以上PHP扩展pdo_mysql, openssl, jsonNginx伪静态按项目实际路由配置MySQL字符集utf8mb4出现空白页时先开PHP错误日志宝塔面板可以在网站设置里打开display_errors快速看到具体报错定位之后下线前记得关掉。常见的是PDOException里的“unknown database”原因是SQL文件导入目标库名和代码里填的库名不一致优先核对这一步。5.3 扩展矩阵题和跳题逻辑的思路这套系统目前主要覆盖单选、多选、填空。如果要加矩阵题不必新增表在question表增加一个row_title字段表示矩阵的行标题比如“外观满意度”“性能满意度”选项可以复用question_option。统计时按row_title option_id组合GROUP BY就能输出矩阵热力数据。跳题逻辑则需要在question表加skip_to字段前端监听答案变化后控制下一题显示。核心表结构不用推翻这是这套设计最大的扩展优势。接口跨域也是前后端分离场景会遇到的问题。如果用Vue等框架单独部署前端PHP接口需要设置CORS响应头或者使用JSONP回调绕过浏览器限制。JSONP只支持GET提交答卷这类写入操作用CORS更合适也避免把问卷答案暴露到服务器访问日志里。矩阵题的统计数据仍然走 answer_detail 和 question_option 的 JOIN接口侧只需多传一个 row_title 参数。整体改动集中在这几个字段不会波及核心流程。本文还有配套的精品资源点击获取
返回列表