问卷考试系统设计(二):题目 DSL 设计——用 JSON 描述一切题型
上一篇聊了整体架构和数据模型,这篇讲整个系统核心的部分:怎么用一套统一的 JSON 结构,把单选、多选、量表、矩阵、排序、填空……所有题型都描述清楚。然后让引擎把这些 JSON 跑起来,条件跳题、题目分组、随机排序,这些才是让问卷从"能编辑"变成"能填写"的关键。
为什么用 JSON 而不是多张表
传统建模的麻烦
直观的做法,每种题型一张表:
CREATE TABLE question_single_choice (
id BIGINT PRIMARY KEY, stem TEXT,
option_a TEXT, option_b TEXT, option_c TEXT, option_d TEXT,
correct_answer VARCHAR(10)
);
CREATE TABLE question_likert (
id BIGINT PRIMARY KEY, stem TEXT,
scale_type VARCHAR(20), is_reverse BOOLEAN
);
-- 10 种题型 10 张表……问题很明显:
- 扩展成本高:新增一种题型,要加表、加 DAO、加 Service,前端还得写一套新组件
- 查询碎:想拿到"某份问卷的所有题目",得 UNION ALL 十张表
- 前端适配累:每种题型一套接口,后端返回的数据结构都不一样
- 导入导出难:不同题型的序列化逻辑各不相同
JSON DSL 方案
核心思路:公共字段放列里(题干、排序、是否必填),差异部分全部塞进一个 JSON 字段。题型变了,改 JSON 结构就行,表结构不动。
CREATE TABLE question (
id BIGINT PRIMARY KEY,
questionnaire_id BIGINT NOT NULL,
type VARCHAR(32) NOT NULL,
stem TEXT NOT NULL,
dsl JSON NOT NULL DEFAULT '{}',
sort_order INT NOT NULL DEFAULT 0,
required BOOLEAN NOT NULL DEFAULT TRUE
);新增题型的成本从“加表 + 改一串代码”变成了“补一段 JSON 示例 + 加校验规则 + 加前端组件”。主表结构不跟着变,后续维护压力会小很多。
DSL 设计原则
- 可扩展:新增题型不改表结构,JSON 里加字段就行
- 前后端通用:同一份 JSON,后端拿来校验,前端拿来渲染,不要搞两套格式
- 人类可读:字段命名用完整的英文单词,产品经理看一眼能大致明白
- 版本化:Schema 带版本号,支持平滑升级
10 种题型的完整定义
所有题型的 DSL 都遵循统一的顶层结构:
{
"$schema_version": "2.0",
"type": "single_choice",
"options": [...],
"scoring": {...}
}题目 DSL 顶层结构把公共字段和题型差异分开,前端与后端读取同一份 JSON:
单选题 single_choice
{
"$schema_version": "2.0",
"type": "single_choice",
"options": [
{ "value": "A", "label": "非常同意", "score": 4 },
{ "value": "B", "label": "同意", "score": 3 },
{ "value": "C", "label": "中立", "score": 2 },
{ "value": "D", "label": "不同意", "score": 1 }
],
"scoring": {
"enabled": true,
"correct": ["B"],
"score_map": { "A": 4, "B": 3, "C": 2, "D": 1 }
}
}options 里每个选项都带 score,评分依据直接写在选项上。value 是存到数据库里的值(不能改),label 是界面上显示的文案(可以随时调)。
多选题 multiple_choice
{
"$schema_version": "2.0",
"type": "multiple_choice",
"options": [
{ "value": "A", "label": "Java" },
{ "value": "B", "label": "Python" },
{ "value": "C", "label": "Go" },
{ "value": "D", "label": "Rust" }
],
"min_select": 1,
"max_select": 3,
"scoring": {
"enabled": true,
"correct": ["A", "B", "D"],
"partial_credit": true
}
}partial_credit 开启时,部分选对也按比例给分。比如正确答案 ABD,用户选了 AB,得 2/3 的分。
量表题 likert
李克特量表,问卷里用得上限的题型:
{
"$schema_version": "2.0",
"type": "likert",
"scale": 5,
"anchors": {
"low": "非常不同意",
"mid": "中立",
"high": "非常同意"
},
"reverse": false,
"scoring": {
"enabled": true,
"score_map": { "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 }
}
}scale 是量表点数(5 点或 7 点),anchors 定义两端和中间的锚点文字。reverse 是反向计分标记,心理量表里非常常见,后面评分引擎那篇会详细讲。
反向题的 DSL 和正向题几乎一样,区别在 reverse: true 和 score_map 反转:
{
"type": "likert",
"scale": 5,
"reverse": true,
"scoring": {
"enabled": true,
"score_map": { "1": 5, "2": 4, "3": 3, "4": 2, "5": 1 }
}
}后端计分时优先查 score_map,所以反转后的分值可以直接由 JSON 配置给出;同时保留 reverse: true,方便质量检测、导出和后续审计。
矩阵题 matrix
批量处理相似问题的高效方式:
{
"$schema_version": "2.0",
"type": "matrix",
"row_label": "请对以下方面进行评价:",
"columns": [
{ "value": "1", "label": "非常不满意" },
{ "value": "2", "label": "不满意" },
{ "value": "3", "label": "一般" },
{ "value": "4", "label": "满意" },
{ "value": "5", "label": "非常满意" }
],
"rows": [
{ "id": "r1", "label": "教学质量" },
{ "id": "r2", "label": "课程内容" },
{ "id": "r3", "label": "教学设施" },
{ "id": "r4", "label": "师生互动" }
],
"scoring": {
"enabled": true,
"score_map": { "1": 1, "2": 2, "3": 3, "4": 4, "5": 5 }
}
}用户提交的答案格式是 { "r1": "4", "r2": "5", "r3": "3", "r4": "4" },key 是 row 的 id,value 是选中的 column value。
填空题 fill_blank
{
"$schema_version": "2.0",
"type": "fill_blank",
"blanks": [
{
"id": "blank_1",
"placeholder": "请输入你的姓名",
"input_type": "text",
"max_length": 50
},
{
"id": "blank_2",
"placeholder": "请输入年龄",
"input_type": "number",
"min": 1,
"max": 150
}
]
}input_type 支持 text、number、email、date,前端根据这个值渲染不同的输入控件。
排序题 ranking
{
"$schema_version": "2.0",
"type": "ranking",
"options": [
{ "value": "salary", "label": "薪资待遇" },
{ "value": "growth", "label": "成长空间" },
{ "value": "balance", "label": "工作生活平衡" },
{ "value": "team", "label": "团队氛围" }
],
"min_rank": 3,
"scoring": { "enabled": false }
}排序题通常不计分,它收集的是偏好数据。用户提交的是一个有序数组:["growth", "salary", "team"]。
问答题 essay
{
"$schema_version": "2.0",
"type": "essay",
"input_type": "textarea",
"min_length": 50,
"max_length": 2000,
"placeholder": "请详细描述你的想法...",
"scoring": {
"enabled": true,
"max_score": 20,
"rubric": "观点明确 10 分,论证充分 10 分"
}
}rubric 是评分标准,人工阅卷时展示给评分者看,能大幅减少评分者的理解偏差。
上传题 upload
{
"$schema_version": "2.0",
"type": "upload",
"accept": [".pdf", ".docx", ".png", ".jpg"],
"max_file_size_mb": 10,
"max_files": 3,
"scoring": { "enabled": true, "max_score": 10 }
}人口学信息题 demographic
收集性别、年龄、学历等基本信息的组合题型:
{
"$schema_version": "2.0",
"type": "demographic",
"fields": [
{
"id": "gender",
"label": "性别",
"input_type": "radio",
"options": [
{ "value": "male", "label": "男" },
{ "value": "female", "label": "女" }
],
"required": true
},
{
"id": "age",
"label": "年龄",
"input_type": "number",
"min": 1,
"max": 120,
"required": true
}
],
"scoring": { "enabled": false }
}题型总览
| 题型 | type 值 | 核心字段 | 典型场景 |
|---|---|---|---|
| 单选题 | single_choice | options, scoring | 考试选择、满意度调查 |
| 多选题 | multiple_choice | options, min/max_select | 技能摸底、多选偏好 |
| 填空题 | fill_blank | blanks[], input_type | 考试填空、信息收集 |
| 量表题 | likert | scale, anchors, reverse | 态度测量、满意度评分 |
| 矩阵题 | matrix | columns[], rows[] | 多维度批量评价 |
| 排序题 | ranking | options, min/max_rank | 优先级偏好调查 |
| 问答题 | essay | min/max_length, rubric | 开放式反馈 |
| 上传题 | upload | accept, max_file_size_mb | 作业提交 |
| 人口学题 | demographic | fields[] | 性别、年龄、学历 |
条件跳题
常见也更实用的需求:根据用户的答案,动态决定下一道题是什么。
举个例子。员工满意度调查,第一题"您是否为管理层?"。选"是"跳到管理能力自评,选"否"跳到工作环境满意度。两种路径互不干扰。
DSL 里用 logic 字段来承载跳转规则:
{
"type": "single_choice",
"options": [
{ "value": "yes", "label": "是" },
{ "value": "no", "label": "否" }
],
"logic": {
"rules": [
{
"condition": { "field": "self", "operator": "equals", "value": "yes" },
"action": "goto",
"target_question_id": "q_manager_rating"
},
{
"condition": { "field": "self", "operator": "equals", "value": "no" },
"action": "skip_to",
"target_question_id": "q_work_env"
}
]
}
}condition.field:self 表示当前题目的答案,也可以引用其他题目的 ID(跨题引用)。
action 四种行为:
goto:跳转到指定题目skip_to:跳过中间所有题目,直接到目标show:动态显示某道默认隐藏的题目hide:动态隐藏某道默认显示的题目
show/hide 比 goto 更常用。goto 适合问卷末尾的分流,show/hide 适合中间某个条件触发后展开或收起一组题目。
引擎实现时有个容易踩的坑:show/hide 不能只在前端做。如果用户触发了 hide 把三道题藏起来然后提交,后端必须知道那三道题是被隐藏的,否则会校验"必填题未作答"。逻辑评估必须在后端也跑一遍。
题目分组(Section)
一份问卷 50 道题不分区,用户体验是灾难。分组逻辑放在问卷级别:
{
"sections": [
{
"id": "sec_basic",
"title": "基本信息",
"description": "请填写您的基本信息",
"question_ids": ["q1", "q2", "q3"]
},
{
"id": "sec_feedback",
"title": "课程反馈",
"question_ids": ["q4", "q5", "q6", "q7"]
}
]
}每个 section 有自己的标题和描述,前端渲染时可以用作分页展示,每组一页,翻页体验比长列表好得多。
分组和跳题配合使用时:引擎在计算跳转时把所有题目拉平成一个有序列表做逻辑计算,分组只在渲染时生效。逻辑层和渲染层各管各的。
随机排序
防止被试形成固定的答题模式,问卷和考试经常需要打乱题目顺序。
题目级随机
{
"settings": {
"randomize_questions": {
"enabled": true,
"scope": "within_section",
"exclude_question_ids": ["q1"]
}
}
}scope: within_section 只在每个分组内部随机。exclude_question_ids 排除不参与随机的题目(如知情同意书、人口学信息)。
选项级随机
{
"type": "single_choice",
"options": [
{ "value": "A", "label": "选项A", "fixed_position": 0 },
{ "value": "B", "label": "选项B" },
{ "value": "C", "label": "选项C" },
{ "value": "D", "label": "以上都不是", "fixed_position": -1 }
],
"randomize_options": true
}fixed_position: 0 固定在第一位,fixed_position: -1 固定在末尾一位,其余随机。
实现细节:随机种子需要跟用户绑定,用 hash(questionnaireId + userId) 作为种子,保证同一用户同一份问卷的随机结果是一致的。前端可以用 seedrandom.js,后端验证时用同样的种子重放。
条件必填
除了简单的 required: true,还有条件必填:
{
"id": "q5",
"required": false,
"required_if": {
"condition": { "field": "q3", "operator": "equals", "value": "yes" },
"message": "您选择了参加课程,请完成课程评价"
}
}引擎提交时遍历所有题目,先评估 required_if 的条件,再决定是否校验必填。
DSL 引擎实现
核心类定义
@Data
public class DslSchema {
private String schemaVersion;
private String type;
private List<Option> options;
private Scoring scoring;
private List<Blank> blanks;
private LikertMeta likert;
private MatrixMeta matrix;
private Logic logic;
private Boolean randomizeOptions;
@Data
public static class Option {
private String value;
private String label;
private Integer score;
private Integer fixedPosition;
}
@Data
public static class Scoring {
private boolean enabled;
private List<String> correct;
private Map<String, Integer> scoreMap;
private boolean partialCredit;
private Integer maxScore;
private String rubric;
}
@Data
public static class Logic {
private List<Rule> rules;
@Data
public static class Rule {
private Condition condition;
private String action;
private String targetQuestionId;
}
@Data
public static class Condition {
private String field;
private String operator;
private Object value;
}
}
}用一个大类承载所有题型的差异字段。看起来字段多,但实际运行时每个题型只用到一部分,其余都是 null。比起给每种题型建子类,这种"扁平大对象"在 JSON 反序列化时更简单,Jackson 一把梭就行。
校验器
题目创建或编辑时校验 DSL 的合法性。不同题型有不同的校验规则:
@Component
public class DslValidator {
public ValidationResult validate(DslSchema schema) {
ValidationResult result = new ValidationResult();
QuestionType type = QuestionType.fromCode(schema.getType());
if (schema.getSchemaVersion() == null) {
result.addError("缺少 $schema_version");
}
switch (type) {
case SINGLE_CHOICE:
case MULTIPLE_CHOICE:
case RANKING:
validateChoice(schema, result);
break;
case LIKERT:
validateLikert(schema, result);
break;
case MATRIX:
validateMatrix(schema, result);
break;
// ... 其他题型
}
return result;
}
private void validateChoice(DslSchema schema, ValidationResult result) {
if (schema.getOptions() == null || schema.getOptions().size() < 2) {
result.addError("选择题至少需要 2 个选项");
}
}
private void validateLikert(DslSchema schema, ValidationResult result) {
if (schema.getLikert() == null) {
result.addError("量表题缺少 likert 配置");
return;
}
Integer scale = schema.getLikert().getScale();
if (scale != 5 && scale != 7) {
result.addError("量表只支持 5 点和 7 点");
}
}
}校验器的核心价值不止防呆。前端也会校验,但用户可能通过 API 直接提交不合法的 DSL,后端必须兜底。
前端 DSL 渲染
前端用 Vue3 的动态组件,根据 dsl.type 自动选子组件:
// 题型注册表
const componentMap: Record<string, () => Promise<Component>> = {
single_choice: () => import('../components/questions/SingleChoice.vue'),
multiple_choice:() => import('../components/questions/MultipleChoice.vue'),
likert: () => import('../components/questions/LikertScale.vue'),
matrix: () => import('../components/questions/MatrixQuestion.vue'),
ranking: () => import('../components/questions/RankingQuestion.vue'),
fill_blank: () => import('../components/questions/FillBlank.vue'),
essay: () => import('../components/questions/EssayQuestion.vue'),
upload: () => import('../components/questions/UploadQuestion.vue'),
demographic: () => import('../components/questions/Demographic.vue'),
}
export function resolveComponent(type: string) {
const loader = componentMap[type]
if (!loader) {
console.warn(`未知题型: ${type}`)
return null
}
return defineAsyncComponent({ loader, delay: 100, timeout: 10000 })
}新增题型时,前端写一个 Vue 组件并注册到 componentMap,后端补类型白名单和校验规则,主流程不用改。组件用 defineAsyncComponent 按需加载,首屏只渲染当前题型。
通用渲染组件很薄,只负责读 type、找组件、传数据:
<template>
<div class="question-renderer">
<div class="stem" v-html="sanitizeHtml(dsl.stemHtml || dsl.stem)"></div>
<component
:is="resolveComponent(dsl.type)"
:schema="dsl"
v-model="value"
/>
</div>
</template>这里如果允许题干带富文本,v-html 前一定要做白名单清洗(比如 DOMPurify),不能直接把后台字符串塞进 DOM。
渲染整份问卷只需要一行:
<QuestionRenderer
v-for="q in visibleQuestions"
:key="q.id"
:question="q"
v-model="answers[q.id]"
/>DSL 版本管理
Schema 版本化是生产环境绕不开的问题。
版本号规则
MAJOR.MINOR
^ ^
| └── 新增字段(向后兼容)
└────── 删除/重命名字段(破坏性变更)MINOR 升级时,旧引擎遇到新字段直接忽略(靠 FAIL_ON_UNKNOWN_PROPERTIES = false),新引擎遇到旧数据用默认值兜底。MAJOR 升级时必须跑迁移。
迁移器
迁移链是单向链表,逐级迁移不做跳跃:
@Component
public class DslMigration {
private static final Map<String, Migration> MIGRATIONS = Map.of(
"1.0→2.0", DslMigration::migrateV1toV2
);
public DslSchema migrate(DslSchema schema) {
String version = schema.getSchemaVersion();
while (!"2.0".equals(version)) {
String key = version + "→" + nextVersion(version);
Migration migration = MIGRATIONS.get(key);
if (migration == null) {
throw new DslMigrationException("无迁移路径: " + key);
}
schema = migration.apply(schema);
version = schema.getSchemaVersion();
}
return schema;
}
}每个迁移函数只处理相邻版本的差异,职责单一。
小结
这篇文章覆盖了 DSL 的完整设计,从 10 种题型的 JSON 规范,到条件跳题、分组、随机排序这些让问卷"能跑起来"的动态特性,再到 Java 引擎的实现和版本管理。
核心思路:用数据描述差异,用引擎驱动行为。所有题型的公共逻辑由引擎统一处理,题型特有的部分通过 JSON 配置描述。新增题型主要是补 DSL 示例、前端组件和校验规则,不改表结构,也不动引擎主流程。
下一篇聊评分引擎,维度计分、反向题处理、标准分转换,以及"常模"到底是什么。
上一篇:问卷考试系统设计(一):架构选型与数据模型 下一篇:问卷考试系统设计(三):评分引擎,维度计分、公式 DSL 与常模