问卷考试系统设计(一):架构选型与数据模型
为什么还要自建一套?
问卷星、腾讯问卷、考试系统都有自己的适用场景,而且这些平台都做得很成熟。普通调研、满意度收集、报名表、简单测评,用它们效率非常高。腾讯问卷有自定义逻辑 / DSL 相关能力,问卷星也支持常见的题目逻辑、API、量表和计分配置。真要只是发一份问卷、收一批答案、导出 Excel,我肯定不会自己造轮子。
我这里说的“问卷考试系统”,更类似是心理量表 + 考试流程 + 报告引擎 + 内部系统集成揉在一起的东西。它不是一个单纯的问卷,也不是一个单纯的考试。踩坑之后我发现,问题不在于现成平台有没有某个功能,而在于这些功能是不是能按我们的方式组合、审计、复用和长期维护。
现成问卷平台的边界
问卷星、腾讯问卷这类 SaaS 平台适合快速搭建和运营,优势很明显:
- 题型丰富,跳题、显示逻辑、配额、回收渠道都比较完善
- 常规计分、基础报告、数据导出基本够用
- 高级版/企业版通常还会提供 API、团队协作、品牌定制、更多逻辑能力
- 对非研发团队很友好,产品、运营、老师自己就能配置
但到心理量表和内部业务系统结合时,边界就开始出现了。
第一,算法可控性不够。 SCL-90 有 9 个因子分,CES-D 有正性情感题需要反向计分,MMPI 又有 L/F/K 等效度量表。平台可能支持“维度计分”或者“反向题”,但我们还会遇到更细的规则:题目版本修订、常模分层、测谎阈值、T 分/Z 分转换、异常答题判定、人工复核标记。只要规则需要被代码审计、被测试用例覆盖、被历史版本追溯,单靠页面配置就会吃力。
第二,报告不是简单的结果页。 心理测评的报告往往不是“你得了 78 分”这么简单。它要解释每个维度的含义,要有雷达图、柱状图、风险提示、建议话术,还要能输出 PDF、归档、推送到业务系统。模板一多,报告本身就变成了一个小型内容系统。
第三,数据合规和私有化要求绕不开。 有些答卷数据涉及个人状态、组织内部评估、学校或企业的敏感信息。即使平台本身安全可靠,业务上也可能要求数据留在自己的库里,权限走自己的组织架构,审计走自己的日志链路。
第四,长期成本和集成成本要算总账。 SaaS 平台按版本、账号、回收量、API 能力收费,短期省事,长期不一定便宜。更麻烦的是集成:用户体系、组织架构、权限、报告归档、消息通知、数据看板都要打通,越往后越类似在平台外面再套一层系统。
所以我末尾的判断是:现成平台并非不能用,重点是如果核心价值在“量表算法和报告自动化”上,自建会更可控。
考试系统模型哪里不贴合
考试系统当然也能改。它的流程很完整:题库、试卷、答题、交卷、评分、成绩统计,和我们要做的东西确实很类似。
问题在于考试系统默认有一套很强的假设:答案有对错,题目有标准答案,分数代表掌握程度。心理测评不是这个模型。“近段时间一周你感到情绪低落的频率”这种题,选“没有”和选“经常”只是反映状态,不存在谁更正确。反向题也并非为了“迷惑考生”,重点是为了修正量表方向和控制作答质量。
再比如考试系统常见的防作弊逻辑是切屏检测、人脸识别、随机抽题;心理量表更关心直线作答、过快作答、矛盾回答、测谎题异常。看起来都是“质量控制”,实际关注点完全不同。
什么时候值得自建
我的经验是,不要一上来就自建。先用这个清单判断:
| 场景 | 更适合现成平台 | 更适合自建 |
|---|---|---|
| 问卷规模 | 临时调研、活动报名、满意度收集 | 长期产品能力,需要持续迭代 |
| 计分规则 | 总分、简单维度、固定报告 | 多维度、反向计分、常模、效度量表、复杂阈值 |
| 数据要求 | 可接受平台托管和导出 | 私有化、审计、权限、合规要求强 |
| 系统集成 | Excel 导出即可 | 要打通用户、组织、业务流程、消息、档案 |
| 成本模型 | 短期使用,人数不大 | 长期高频使用,企业版/API 成本明显 |
| 可维护性 | 配置改完就结束 | 算法要测试、版本要追溯、报告要复用 |
如果只是普通问卷,用成熟平台更省心;如果要做成企业内部长期运行的测评能力,那就需要一套更工程化的系统。
我们到底需要什么
所以这里要做的并非“替代问卷星”,重点是做一个更贴合业务的混合系统。它得同时满足几个需求:
- 灵活的题目定义,不同量表差异巨大,单选、多选、Likert 量表、矩阵题都得支持,不能每次加题型就改数据库表结构
- 可审计的计分逻辑,正向计分、反向计分、维度分组、标准分换算、测谎题检测,这些规则要能落到代码和测试里
- 自动出报告,答题结束后直接生成带图表、解释和建议的测评报告,必要时输出 PDF,别让人手工算分再贴到 Word 里
- 能接入内部系统,用户、组织、权限、消息、档案、审计都能和现有平台打通
- 版本可追溯,量表修订、题目调整、常模更新都要保留历史,不能新规则覆盖旧答卷
听起来挺折腾的,但核心就一件事:把心理测量师手工做的事情自动化,同时把每一步都做成可维护的工程能力。
心理问卷 vs 普通问卷的区别
这个系列会反复提到"心理问卷"和"普通问卷"的差异,这里先厘清一下:
维度 心理问卷(量表) 普通问卷(调查/考试) 计分逻辑 Likert 程度量表,无对错 对/错二元 或 不计分 反向题 存在,需反转分值 通常不存在 维度结构 心理学维度(焦虑、抑郁等) 章节/知识点 常模参照 需要,T 分/Z 分转换 不需要 质量控制 测谎题、一致性检测 防作弊 后面的文章会根据这些差异分别讨论实现方案。
整体架构
整个系统分三大块:

核心设计:题目用 JSON DSL 描述,评分规则内嵌在 DSL 里,前端渲染和后端计分都基于同一份 DSL 解释执行。
这个设计解决了一个很现实的问题,“前端一份配置、后端再维护一份"的双写。你肯定遇到过这种 bug:题目的选项前端显示的分值是 1/2/3/4,后端评分逻辑里写的是 0/1/2/3,改了一边忘了改另一边,用户交完卷一看分数对不上。DSL 的方案就是把这份定义只写一份,前后端都读同一份,从根上杜绝不一致。
同一份 DSL 要同时服务前端渲染和后端评分,关键是发布后形成不可变快照:
三个引擎
后端的核心是三个引擎,各自独立、互不耦合:
- DSL 引擎:解析题目 JSON,做格式校验,给前端生成渲染指令。它就类似一个翻译官,把后端存储的结构化数据翻译成前端能理解的渲染指令
- 评分引擎:拿到答题记录和 DSL,按维度分组计分,处理反向题、测谎题、标准分转换。这是整个系统核心的部分
- 报告引擎:拿到评分结果,生成带图表的测评报告,输出 PDF。这一步比较吃资源,所以放到了异步任务里
为什么要拆这么细?因为这三个东西的变化频率完全不一样。题型可能经常加,今天产品说要加个滑块题,明天又说要支持矩阵量表;评分算法偶尔变,某个量表出了新版常模,分界值要调整;报告模板相对稳定,改改措辞、加个图表类型而已。如果把这些逻辑揉在一起,改一个地方可能牵一发动全身。
技术栈
- 后端:Spring Boot 3 + MyBatis-Plus
- 前端:Vue3 + Element Plus / Naive UI
- 数据库:MySQL 8(JSON 字段存 DSL)
- 缓存:Redis
- 异步任务:Snail Job 或 XXL-JOB
- PDF 生成:Flying Saucer(HTML + CSS → PDF)
- 图表渲染:ECharts + Puppeteer(服务端截图)
核心数据模型
整个系统围绕四层模型展开:问卷 → 试卷 → 答卷 → 报告。每层对应几张表,职责清晰。
questionnaire — 问卷/量表主表
一个量表就是一条记录。code 是业务唯一标识,version 用来做版本管理,量表修订了不删旧版,新建一条 version+1 的记录。category 区分问卷类型:MENTAL(心理量表)、EXAM(考试)、SURVEY(调查)。不同类型走不同的处理流程,心理量表需要维度计分和常模参照,普通问卷可能只做简单汇总。
CREATE TABLE questionnaire (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
code VARCHAR(64) NOT NULL COMMENT '量表编码,如 SDS、SCL90',
title VARCHAR(256) NOT NULL COMMENT '量表名称',
description TEXT COMMENT '指导语',
category VARCHAR(64) NOT NULL DEFAULT 'COMMON' COMMENT '分类:MENTAL/EXAM/SURVEY/COMMON',
scoring_method VARCHAR(32) NOT NULL DEFAULT 'SUM' COMMENT '计分方式:SUM/WEIGHTED/CUSTOM',
total_duration INT DEFAULT NULL COMMENT '建议答题时长(分钟)',
version INT NOT NULL DEFAULT 1 COMMENT '版本号',
status TINYINT NOT NULL DEFAULT 1 COMMENT '0-草稿 1-已发布 2-已归档',
extra JSON COMMENT '扩展配置',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
UNIQUE KEY uk_code_version (code, version),
INDEX idx_category (category),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='问卷/量表主表';question — 题目表
每道题用一个 dsl JSON 字段描述完整结构,题型、题干、选项、维度归属、是否反向计分,全在里面。is_reverse 和 is_lie_scale 单独拎出来当列而不是放在 JSON 里,是因为查询频率高,评分的时候要遍历所有题目按这两个标记分组处理。
CREATE TABLE question (
id BIGINT PRIMARY KEY AUTO_INCREMENT COMMENT '主键ID',
questionnaire_id BIGINT NOT NULL COMMENT '所属问卷ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序',
dimension VARCHAR(64) DEFAULT NULL COMMENT '维度编码',
is_reverse TINYINT NOT NULL DEFAULT 0 COMMENT '是否反向计分',
is_lie_scale TINYINT NOT NULL DEFAULT 0 COMMENT '是否测谎题',
dsl JSON NOT NULL COMMENT '题目 DSL(核心字段)',
dsl_hash VARCHAR(64) DEFAULT NULL COMMENT 'DSL 的 SHA-256,变更检测用',
version INT NOT NULL DEFAULT 1,
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_questionnaire (questionnaire_id),
INDEX idx_dimension (questionnaire_id, dimension),
INDEX idx_sort (questionnaire_id, sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表';exam_paper — 试卷表
问卷定义了"有哪些题”,试卷定义了"这次用哪些题、什么顺序"。同一个问卷可以套多套试卷,随机抽题、AB 卷、练习卷、正式卷,都通过这张表实现。
CREATE TABLE exam_paper (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
questionnaire_id BIGINT NOT NULL,
title VARCHAR(256) NOT NULL,
question_ids JSON NOT NULL COMMENT '题目ID有序数组',
shuffle TINYINT NOT NULL DEFAULT 0 COMMENT '是否打乱顺序',
time_limit INT DEFAULT NULL COMMENT '限时(分钟)',
pass_score DECIMAL(8,2) DEFAULT NULL COMMENT '及格分数',
status TINYINT NOT NULL DEFAULT 1,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT NOT NULL DEFAULT 0,
INDEX idx_questionnaire (questionnaire_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='试卷表';answer_sheet — 答卷表
记录一次完整的答题会话。status 的状态流转是:答题中(0) → 已提交(1) → 已评分(2) → 已生成报告(3) → 已作废(4)。这个状态机很重要,前端页面的展示逻辑和后端的流程控制都依赖它。
CREATE TABLE answer_sheet (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
paper_id BIGINT NOT NULL,
questionnaire_id BIGINT NOT NULL COMMENT '冗余,方便查询',
user_id BIGINT NOT NULL,
start_time DATETIME DEFAULT NULL,
submit_time DATETIME DEFAULT NULL,
duration_sec INT DEFAULT NULL COMMENT '实际用时(秒)',
status TINYINT NOT NULL DEFAULT 0,
extra JSON COMMENT '客户端IP、设备信息等',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_paper (paper_id),
INDEX idx_questionnaire (questionnaire_id, user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='答卷表';answer_record — 答题记录表
每道题的回答存在这里。answer_value 存原始答案,answer_index 存选项索引(0-based),score 是评分引擎算完后回填的分数。注意这张表没有 deleted 字段,答题记录是不可删的,属于审计数据。
CREATE TABLE answer_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sheet_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
answer_value VARCHAR(512) NOT NULL COMMENT '答题值',
answer_index INT DEFAULT NULL COMMENT '选项索引(0-based)',
score DECIMAL(8,2) DEFAULT NULL COMMENT '本题得分(评分后回填)',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sheet (sheet_id),
INDEX idx_question (question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='答题记录表';score_result — 评分结果表
评分引擎的输出,按维度汇总。raw_score 是原始分,std_score 是标准分(经过公式换算的)。level_code 和 level_desc 是等级判定结果。比如 SDS 标准分 < 53 是正常,53-62 是轻度抑郁,63-72 是中度,> 72 是重度。
CREATE TABLE score_result (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sheet_id BIGINT NOT NULL,
dimension VARCHAR(64) DEFAULT NULL COMMENT '维度编码,NULL=总分',
raw_score DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '原始分',
std_score DECIMAL(10,2) DEFAULT NULL COMMENT '标准分',
item_count INT NOT NULL DEFAULT 0,
max_score DECIMAL(10,2) DEFAULT NULL,
min_score DECIMAL(10,2) DEFAULT NULL,
level_code VARCHAR(32) DEFAULT NULL COMMENT '等级编码',
level_desc VARCHAR(128) DEFAULT NULL COMMENT '等级描述',
extra JSON COMMENT '扩展数据',
scored_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_sheet (sheet_id),
UNIQUE KEY uk_sheet_dimension (sheet_id, dimension)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='评分结果表';report — 报告表
content_html 是报告正文 HTML,content_json 是结构化 JSON 给前端自定义渲染用,pdf_url 是 PDF 文件地址,chart_urls 存图表图片地址数组。
CREATE TABLE report (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
sheet_id BIGINT NOT NULL,
user_id BIGINT NOT NULL COMMENT '冗余',
questionnaire_id BIGINT NOT NULL COMMENT '冗余',
title VARCHAR(256) NOT NULL,
summary TEXT COMMENT '报告摘要',
content_html MEDIUMTEXT COMMENT '报告正文 HTML',
content_json JSON COMMENT '结构化数据',
pdf_url VARCHAR(512) DEFAULT NULL,
chart_urls JSON COMMENT '图表图片地址',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0-生成中 1-已完成 2-失败',
generated_at DATETIME DEFAULT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_user (user_id),
INDEX idx_sheet (sheet_id),
INDEX idx_questionnaire (questionnaire_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='测评报告表';表关系总览
核心表关系可以按“配置快照”和“作答结果”两条线看:
数据流向:题库 → 试卷 → 答卷 → 评分 → 报告。每一层的产出都是下一层的输入,边界很清楚。
小结
总结一下:
- 为什么自建,并非现成平台不能用,重点是复杂心理量表、私有化、算法审计、报告自动化和内部集成让自建更合适
- 整体架构,前端 Vue3 + 后端 Spring Boot,核心是 DSL 引擎、评分引擎、报告引擎三个独立模块
- 数据模型,7 张表,问卷 → 试卷 → 答卷 → 报告四层模型,题目用 JSON DSL 描述
下一篇会深入 DSL 的设计,怎么用一套统一的 JSON 结构描述所有题型,以及 DSL 引擎的条件跳题、分组、版本管理等高级特性。