← 返回

问卷考试系统设计(二):题目 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:

flowchart TB Q["Question DSL"] --> Common["公共字段<br/>id、type、title、required"] Q --> Options["options<br/>选项、分值、显示文本"] Q --> Scoring["scoring<br/>计分、反向题、维度"] Q --> Logic["logic<br/>显示条件、跳题规则"] Q --> Types["题型差异字段"] Types --> Single["single_choice"] Types --> Multi["multi_choice"] Types --> Matrix["matrix_scale"] Types --> Text["text_input"] Common --> Front["前端渲染"] Options --> Front Logic --> Front Options --> Back["后端校验"] Scoring --> Back Logic --> Back

单选题 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: truescore_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 支持 textnumberemaildate,前端根据这个值渲染不同的输入控件。

排序题 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_choiceoptions, scoring考试选择、满意度调查
多选题multiple_choiceoptions, min/max_select技能摸底、多选偏好
填空题fill_blankblanks[], input_type考试填空、信息收集
量表题likertscale, anchors, reverse态度测量、满意度评分
矩阵题matrixcolumns[], rows[]多维度批量评价
排序题rankingoptions, min/max_rank优先级偏好调查
问答题essaymin/max_length, rubric开放式反馈
上传题uploadaccept, max_file_size_mb作业提交
人口学题demographicfields[]性别、年龄、学历

条件跳题

常见也更实用的需求:根据用户的答案,动态决定下一道题是什么。

举个例子。员工满意度调查,第一题"您是否为管理层?"。选"是"跳到管理能力自评,选"否"跳到工作环境满意度。两种路径互不干扰。

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.fieldself 表示当前题目的答案,也可以引用其他题目的 ID(跨题引用)。

action 四种行为:

  • goto:跳转到指定题目
  • skip_to:跳过中间所有题目,直接到目标
  • show:动态显示某道默认隐藏的题目
  • hide:动态隐藏某道默认显示的题目

show/hidegoto 更常用。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 与常模