当前系统已经完成从 phase1 到 phase2 的演化。
phase1 的核心模型是“问卷包含题目”,phase2 的核心模型变为“问卷引用题目版本”。
因此数据库设计也从最初的 3 个核心集合,扩展为当前的 6 个核心集合:
userssurveysresponsesquestion_rootsquestion_versionsquestion_banks
设计原则可以概括为:
- 题目资产独立存在,支持复用、共享和版本演化
- 问卷保留自己的编排语义,包括顺序、跳转和快照
- 答卷既保留 phase1 兼容结构,也支持 phase2 跨问卷统计
- 已发布问卷必须保持稳定,不受题目后续修改影响
| 集合 | 主要职责 |
|---|---|
users |
用户认证与对象归属 |
question_roots |
题目资产身份层 |
question_versions |
题目具体内容版本层 |
question_banks |
题目组织容器 |
surveys |
问卷元信息与题目引用编排 |
responses |
答卷与统计数据来源 |
{
"_id": ObjectId,
"username": "alice",
"password_hash": "$2b$12$...",
"created_at": ISODate("2026-01-01T00:00:00Z")
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 主键 |
username |
String | 用户名,全局唯一 |
password_hash |
String | bcrypt 哈希 |
created_at |
Date | 注册时间 |
db.users.createIndex({ "username": 1 }, { unique: true })
用户是独立实体,问卷、题目资产和题库都通过 owner_id / creator_id 指向用户。
不嵌入其它集合的原因是:一个用户可能拥有多个问卷、题目和题库,嵌入会产生大量冗余。
question_root 表示“同一道题”的长期身份,是 phase2 引入的最关键对象。
它负责解决的问题是:
- 这道题归谁所有
- 这道题共享给谁
- 这道题最新版本是谁
- 这道题在哪些题库中
{
"_id": ObjectId,
"owner_id": ObjectId,
"visibility": "private",
"shared_with": [ObjectId],
"latest_version_id": ObjectId,
"bank_ids": [ObjectId],
"created_at": ISODate,
"updated_at": ISODate
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 题目资产主键 |
owner_id |
ObjectId | 所有者 |
visibility |
String | private / shared / public |
shared_with |
Array<ObjectId> | 被共享给的用户列表 |
latest_version_id |
ObjectId | 当前最新版本 |
bank_ids |
Array<ObjectId> | 所属题库列表 |
created_at |
Date | 创建时间 |
updated_at |
Date | 最近更新时间 |
db.question_roots.createIndex({ "owner_id": 1, "updated_at": -1 })
db.question_roots.createIndex({ "visibility": 1 })
db.question_roots.createIndex({ "shared_with": 1 })
question_root 与 question_version 分离,是 phase2 支持“同题多版本并存”的基础。
如果把身份和内容揉在一条记录里,就很难同时满足:
- 同一道题不断演化
- 历史版本可追溯
- 不同问卷使用不同版本
question_version 表示某一道题在某个时间点的具体内容。
{
"_id": ObjectId,
"root_id": ObjectId,
"version": 3,
"parent_version_id": ObjectId,
"title": "你的年龄(周岁)",
"type": "number_input",
"required": true,
"options": null,
"validation": {
"min_select": null,
"max_select": null,
"min_length": null,
"max_length": null,
"min_value": 0,
"max_value": 120,
"is_integer": true
},
"created_at": ISODate
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 版本主键 |
root_id |
ObjectId | 所属题目资产 |
version |
Int | 版本号,从 1 开始递增 |
parent_version_id |
ObjectId | null | 父版本,用于构建版本链 |
title |
String | 题目标题 |
type |
String | single_choice / multi_choice / text_input / number_input |
required |
Boolean | 是否必答 |
options |
Array | null | 选择题选项 |
validation |
Object | 校验规则 |
created_at |
Date | 版本创建时间 |
db.question_versions.createIndex({ "root_id": 1, "version": 1 }, { unique: true })
db.question_versions.createIndex({ "root_id": 1, "created_at": -1 })
题目内容修改不再是 update 旧记录,而是新增一条 question_version。
这样可以保证:
- 已发布问卷引用的旧版本保持不变
- 可以查看版本历史
- 可以从旧版本恢复出一个新的最新版本
题库是题目资产的组织容器,不是题目本体。
{
"_id": ObjectId,
"owner_id": ObjectId,
"name": "基础人口信息题库",
"description": "常用的人口统计题目",
"shared_with": [ObjectId],
"question_root_ids": [ObjectId],
"created_at": ISODate,
"updated_at": ISODate
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 题库主键 |
owner_id |
ObjectId | 所有者 |
name |
String | 题库名称 |
description |
String | 描述 |
shared_with |
Array<ObjectId> | 共享给的用户 |
question_root_ids |
Array<ObjectId> | 收录的题目资产 |
created_at |
Date | 创建时间 |
updated_at |
Date | 最近更新时间 |
db.question_banks.createIndex({ "owner_id": 1, "updated_at": -1 })
db.question_banks.createIndex({ "shared_with": 1 })
题库只做“组织”,不做“内容所有者”。
题目资产仍由 question_root 统一管理,这样可以避免题目内容、题库组织和问卷编排三种语义混在一起。
问卷在 phase2 中仍然是核心对象,但其内部 questions[] 的语义已经改变:
它不再保存题目本体,而是保存“题目版本在问卷中的引用关系”。
{
"_id": ObjectId,
"creator_id": ObjectId,
"title": "用户满意度调查",
"description": "请花 2 分钟填写",
"allow_anonymous": false,
"allow_multiple_submit": true,
"status": "draft",
"display_mode": "progressive",
"deadline": null,
"share_code": "abc123",
"response_count": 0,
"created_at": ISODate,
"updated_at": ISODate,
"questions": [
{
"qid": "q1",
"order": 1,
"question_root_id": ObjectId,
"question_version_id": ObjectId,
"snapshot": {
"title": "你的年龄",
"type": "number_input",
"required": true,
"options": null,
"validation": {
"min_select": null,
"max_select": null,
"min_length": null,
"max_length": null,
"min_value": 0,
"max_value": 120,
"is_integer": true
}
},
"jump_rules": [
{
"condition": {
"type": "simple",
"operator": "gte",
"value": 18
},
"target": "__order__:3"
}
]
}
]
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 问卷主键 |
creator_id |
ObjectId | 创建者 |
title |
String | 标题 |
description |
String | 说明 |
allow_anonymous |
Boolean | 是否允许匿名 |
allow_multiple_submit |
Boolean | 是否允许重复提交 |
status |
String | draft / published / closed |
display_mode |
String | progressive / all_at_once |
deadline |
Date | null | 截止时间 |
share_code |
String | 分享码 |
response_count |
Int | 冗余答卷数 |
created_at |
Date | 创建时间 |
updated_at |
Date | 更新时间 |
questions |
Array | 问卷中的题目引用列表 |
| 字段 | 类型 | 说明 |
|---|---|---|
qid |
String | 问卷内部标识 |
order |
Int | 当前问卷中的顺序 |
question_root_id |
ObjectId | null | 引用的题目资产 |
question_version_id |
ObjectId | null | 引用的题目版本 |
snapshot |
Object | 当前问卷冻结的题目内容 |
jump_rules |
Array | 当前问卷中的跳转规则 |
问卷中保留 snapshot,而不是只保存引用,原因是:
- 发布后的问卷必须稳定
- 问卷渲染时不必每次跨集合拼接题目内容
- 旧答卷统计时能对应到当时的题目内容
当前跳转目标支持:
__end____order__:N
并且服务端始终限制为前跳,不允许:
- 跳到当前题
- 跳到更早的题
- 形成循环
db.surveys.createIndex({ "creator_id": 1, "updated_at": -1 })
db.surveys.createIndex({ "share_code": 1 }, { unique: true })
db.surveys.createIndex({ "status": 1 })
答卷仍然独立存储,但 phase2 中增加了标准化回答项,以支持跨问卷统计。
{
"_id": ObjectId,
"survey_id": ObjectId,
"respondent_id": ObjectId,
"submitted_at": ISODate,
"ip_address": "192.168.1.1",
"duration_seconds": null,
"answers": {
"q1": "男",
"q2": ["苹果", "西瓜"],
"q3": 25
},
"answer_items": [
{
"qid": "q1",
"question_root_id": ObjectId,
"question_version_id": ObjectId,
"answer": "男"
}
]
}| 字段 | 类型 | 说明 |
|---|---|---|
_id |
ObjectId | 答卷主键 |
survey_id |
ObjectId | 所属问卷 |
respondent_id |
ObjectId | null | 填写者 |
submitted_at |
Date | 提交时间 |
ip_address |
String | 客户端 IP |
duration_seconds |
Int | null | 耗时预留 |
answers |
Object | phase1 兼容答案结构 |
answer_items |
Array | phase2 统计索引结构 |
| 字段 | 作用 |
|---|---|
answers |
保持 phase1 兼容,便于按问卷内部 qid 读取 |
answer_items |
支持按题目资产或版本跨问卷聚合 |
这种双结构虽然有冗余,但能明显降低 phase1 -> phase2 迁移成本。
db.responses.createIndex({ "survey_id": 1 })
db.responses.createIndex({ "survey_id": 1, "respondent_id": 1 })
db.responses.createIndex({ "answer_items.question_root_id": 1 })
db.responses.createIndex({ "answer_items.question_version_id": 1 })
MongoDB 在本项目中的优势主要体现在:
| 场景 | MongoDB 优势 |
|---|---|
| 问卷内部引用 + 快照 | 文档嵌套自然表达 |
| 题目校验规则 | 可灵活存储不同题型的 validation |
| 跳转规则 | 数组 + 对象结构表达清晰 |
| 答卷答案 | Map 与数组混合结构方便 |
| 版本演化 | 通过多集合引用表达灵活 |
如果使用关系数据库,需要拆成更多表并做多次 JOIN,尤其在:
- 问卷渲染
- 版本引用
- 跳转与快照展示
这些场景下,查询和组装逻辑都会显著复杂。
phase1 的数据库设计重点是:
- 问卷完整读取
- 答卷独立提交
- 跳转与校验嵌入题目
phase2 的数据库设计重点则变为:
- 题目资产独立存在
- 题目版本可演化
- 问卷保存引用与快照
- 答卷支持按题目聚合统计
因此当前数据库设计的最准确概括是:
在保留原问卷主链的基础上,
通过引入题目资产、题目版本、题库和标准化回答项,
把系统从“问卷中心”演化为“问卷 + 题目资产双中心”结构。