← 返回

问卷考试系统设计(一):架构选型与数据模型

为什么还要自建一套?

问卷星、腾讯问卷、考试系统都有自己的适用场景,而且这些平台都做得很成熟。普通调研、满意度收集、报名表、简单测评,用它们效率非常高。腾讯问卷有自定义逻辑 / DSL 相关能力,问卷星也支持常见的题目逻辑、API、量表和计分配置。真要只是发一份问卷、收一批答案、导出 Excel,我肯定不会自己造轮子。

我这里说的“问卷考试系统”,更类似是心理量表 + 考试流程 + 报告引擎 + 内部系统集成揉在一起的东西。它不是一个单纯的问卷,也不是一个单纯的考试。踩坑之后我发现,问题不在于现成平台有没有某个功能,而在于这些功能是不是能按我们的方式组合、审计、复用和长期维护。

现成问卷平台的边界

问卷星、腾讯问卷这类 SaaS 平台适合快速搭建和运营,优势很明显:

  • 题型丰富,跳题、显示逻辑、配额、回收渠道都比较完善
  • 常规计分、基础报告、数据导出基本够用
  • 高级版/企业版通常还会提供 API、团队协作、品牌定制、更多逻辑能力
  • 对非研发团队很友好,产品、运营、老师自己就能配置

但到心理量表和内部业务系统结合时,边界就开始出现了。

第一,算法可控性不够。 SCL-90 有 9 个因子分,CES-D 有正性情感题需要反向计分,MMPI 又有 L/F/K 等效度量表。平台可能支持“维度计分”或者“反向题”,但我们还会遇到更细的规则:题目版本修订、常模分层、测谎阈值、T 分/Z 分转换、异常答题判定、人工复核标记。只要规则需要被代码审计、被测试用例覆盖、被历史版本追溯,单靠页面配置就会吃力。

第二,报告不是简单的结果页。 心理测评的报告往往不是“你得了 78 分”这么简单。它要解释每个维度的含义,要有雷达图、柱状图、风险提示、建议话术,还要能输出 PDF、归档、推送到业务系统。模板一多,报告本身就变成了一个小型内容系统。

第三,数据合规和私有化要求绕不开。 有些答卷数据涉及个人状态、组织内部评估、学校或企业的敏感信息。即使平台本身安全可靠,业务上也可能要求数据留在自己的库里,权限走自己的组织架构,审计走自己的日志链路。

第四,长期成本和集成成本要算总账。 SaaS 平台按版本、账号、回收量、API 能力收费,短期省事,长期不一定便宜。更麻烦的是集成:用户体系、组织架构、权限、报告归档、消息通知、数据看板都要打通,越往后越类似在平台外面再套一层系统。

所以我末尾的判断是:现成平台并非不能用,重点是如果核心价值在“量表算法和报告自动化”上,自建会更可控

考试系统模型哪里不贴合

考试系统当然也能改。它的流程很完整:题库、试卷、答题、交卷、评分、成绩统计,和我们要做的东西确实很类似。

问题在于考试系统默认有一套很强的假设:答案有对错,题目有标准答案,分数代表掌握程度。心理测评不是这个模型。“近段时间一周你感到情绪低落的频率”这种题,选“没有”和选“经常”只是反映状态,不存在谁更正确。反向题也并非为了“迷惑考生”,重点是为了修正量表方向和控制作答质量。

再比如考试系统常见的防作弊逻辑是切屏检测、人脸识别、随机抽题;心理量表更关心直线作答、过快作答、矛盾回答、测谎题异常。看起来都是“质量控制”,实际关注点完全不同。

什么时候值得自建

我的经验是,不要一上来就自建。先用这个清单判断:

场景更适合现成平台更适合自建
问卷规模临时调研、活动报名、满意度收集长期产品能力,需要持续迭代
计分规则总分、简单维度、固定报告多维度、反向计分、常模、效度量表、复杂阈值
数据要求可接受平台托管和导出私有化、审计、权限、合规要求强
系统集成Excel 导出即可要打通用户、组织、业务流程、消息、档案
成本模型短期使用,人数不大长期高频使用,企业版/API 成本明显
可维护性配置改完就结束算法要测试、版本要追溯、报告要复用

如果只是普通问卷,用成熟平台更省心;如果要做成企业内部长期运行的测评能力,那就需要一套更工程化的系统。

我们到底需要什么

所以这里要做的并非“替代问卷星”,重点是做一个更贴合业务的混合系统。它得同时满足几个需求:

  1. 灵活的题目定义,不同量表差异巨大,单选、多选、Likert 量表、矩阵题都得支持,不能每次加题型就改数据库表结构
  2. 可审计的计分逻辑,正向计分、反向计分、维度分组、标准分换算、测谎题检测,这些规则要能落到代码和测试里
  3. 自动出报告,答题结束后直接生成带图表、解释和建议的测评报告,必要时输出 PDF,别让人手工算分再贴到 Word 里
  4. 能接入内部系统,用户、组织、权限、消息、档案、审计都能和现有平台打通
  5. 版本可追溯,量表修订、题目调整、常模更新都要保留历史,不能新规则覆盖旧答卷

听起来挺折腾的,但核心就一件事:把心理测量师手工做的事情自动化,同时把每一步都做成可维护的工程能力

心理问卷 vs 普通问卷的区别

这个系列会反复提到"心理问卷"和"普通问卷"的差异,这里先厘清一下:

维度心理问卷(量表)普通问卷(调查/考试)
计分逻辑Likert 程度量表,无对错对/错二元 或 不计分
反向题存在,需反转分值通常不存在
维度结构心理学维度(焦虑、抑郁等)章节/知识点
常模参照需要,T 分/Z 分转换不需要
质量控制测谎题、一致性检测防作弊

后面的文章会根据这些差异分别讨论实现方案。


整体架构

整个系统分三大块:

问卷考试系统整体架构图

核心设计:题目用 JSON DSL 描述,评分规则内嵌在 DSL 里,前端渲染和后端计分都基于同一份 DSL 解释执行。

这个设计解决了一个很现实的问题,“前端一份配置、后端再维护一份"的双写。你肯定遇到过这种 bug:题目的选项前端显示的分值是 1/2/3/4,后端评分逻辑里写的是 0/1/2/3,改了一边忘了改另一边,用户交完卷一看分数对不上。DSL 的方案就是把这份定义只写一份,前后端都读同一份,从根上杜绝不一致。

同一份 DSL 要同时服务前端渲染和后端评分,关键是发布后形成不可变快照:

flowchart LR A["题库 DSL"] --> B["发布版本快照"] B --> C["前端渲染题型、选项、跳题"] C --> D["答题数据"] B --> E["后端校验与评分规则"] D --> E E --> F["维度分与质量结果"] F --> G["报告生成"] B --> G

三个引擎

后端的核心是三个引擎,各自独立、互不耦合:

  • 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_reverseis_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_codelevel_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='测评报告表';

表关系总览

核心表关系可以按“配置快照”和“作答结果”两条线看:

erDiagram questionnaire ||--o{ question : contains questionnaire ||--o{ exam_paper : publishes exam_paper ||--o{ answer_sheet : receives answer_sheet ||--o{ answer_record : has answer_sheet ||--|| score_result : produces score_result ||--|| report : renders questionnaire { bigint id int version string status } question { bigint id bigint questionnaire_id int version } exam_paper { bigint id bigint questionnaire_id string snapshot_hash } answer_sheet { bigint id bigint exam_paper_id string snapshot_hash }

数据流向:题库 → 试卷 → 答卷 → 评分 → 报告。每一层的产出都是下一层的输入,边界很清楚。


小结

总结一下:

  1. 为什么自建,并非现成平台不能用,重点是复杂心理量表、私有化、算法审计、报告自动化和内部集成让自建更合适
  2. 整体架构,前端 Vue3 + 后端 Spring Boot,核心是 DSL 引擎、评分引擎、报告引擎三个独立模块
  3. 数据模型,7 张表,问卷 → 试卷 → 答卷 → 报告四层模型,题目用 JSON DSL 描述

下一篇会深入 DSL 的设计,怎么用一套统一的 JSON 结构描述所有题型,以及 DSL 引擎的条件跳题、分组、版本管理等高级特性。

下一篇:问卷考试系统设计(二):题目 DSL 设计,用 JSON 描述一切题型