数字无极小程序问答设计
1. 需求
-
一个展览 = 一个或多个问答流程(这里先按一个流程来设计,可扩展多流程)。
-
每个流程有多道题,题型包括:
- 单选题
single_choice - 多选题
multiple_choice - 判断题
true_false(本质是特殊单选) - 填空题 / 简单文本题
text
- 单选题
-
答案形式要支持:
- 标准对/错:答对给分,答错不给分 / 扣分。
- 心理测验式:每个选项对应不同分数(甚至是多维度分数),没有“对错”,只有“倾向”。
-
希望:
- 结构简单/通用,以后扩展题型/计分方式只改 JSON,不大改 schema。
- 前端拿到的是不带正确答案的版本。
- 后端有一个统一的打分引擎。
2. 整体技术方案概览
2.1 核心思路
-
DB 里存的就是一份 JSON 配置(
quiz_config),里面包含:- 问题列表(题干 + 选项)
- 每题的计分规则(不直接暴露给前端)
- 结果规则(总分 / 维度分 → 文案 + 奖励)
-
Go 后端:
- 用很薄的 struct 描一下 JSON 的形状,复杂字段一律
json.RawMessage或map[string]any。 - 写一个统一的
Evaluate()打分函数,根据scoring.mode分发。
- 用很薄的 struct 描一下 JSON 的形状,复杂字段一律
-
请求/响应:
GET /api/v1/quiz/:id→ 返回「无答案版」的题目配置。POST /api/v1/quiz/:id/submit→ 接收用户答案,后端用完整配置打分 → 返回总分、维度分、结果、奖励。
3. 推荐 JSON 配置格式(既支持标准题,又支持心理测验)
3.1 顶层结构
{
"id": "expo-2025-quiz-01",
"title": "展览互动问答",
"type": "quiz", // quiz / personality / mix ...
"config": {
"shuffle_questions": true,
"shuffle_options": true,
"dimensions": ["E", "I"] // 心理维度(可选)
},
"questions": [ /* 见下 */ ],
"result_rules": [ /* 见下 */ ]
}
3.2 Question 结构(通用)
{
"id": "Q1",
"type": "single_choice", // single_choice / multiple_choice / true_false / text
"title": "下列哪一项是本展馆的核心主题?",
"options": [
{ "id": "Q1_A", "text": "科技与未来" },
{ "id": "Q1_B", "text": "传统手工艺" }
],
"meta": {
"required": true
},
"scoring": {
"mode": "standard_choice",
"config": {
"correct_option_ids": ["Q1_A"],
"score_if_correct": 5,
"score_if_wrong": 0
}
}
}
3.3 多种题型 & 计分方式示例
1)单选 / 多选标准题:standard_choice
{
"id": "Q2",
"type": "multiple_choice",
"title": "以下哪些属于本馆展区?",
"options": [
{ "id": "Q2_A", "text": "未来科技" },
{ "id": "Q2_B", "text": "艺术长廊" },
{ "id": "Q2_C", "text": "户外餐饮区" }
],
"scoring": {
"mode": "standard_choice",
"config": {
"correct_option_ids": ["Q2_A", "Q2_B"],
"partial": true,
"per_option_score": 2,
"wrong_penalty": 0
}
}
}
2)判断题:本质是单选 + standard_choice
{
"id": "Q3",
"type": "true_false",
"title": "本展馆允许携带宠物入内。",
"options": [
{ "id": "Q3_true", "text": "正确" },
{ "id": "Q3_false", "text": "错误" }
],
"scoring": {
"mode": "standard_choice",
"config": {
"correct_option_ids": ["Q3_false"],
"score_if_correct": 3,
"score_if_wrong": 0
}
}
}
3)填空 / 文本题:text_pattern
{
"id": "Q4",
"type": "text",
"title": "你对本次展览的整体评分(1-5 分)是?",
"scoring": {
"mode": "text_pattern",
"config": {
"patterns": [
{ "match": "equals", "value": "5", "score": 5 },
{ "match": "equals", "value": "4", "score": 4 }
],
"default_score": 0
}
}
}
这里的
match可以支持equals / regex / contains等。
4)心理测验式题目:psych_by_option
{
"id": "Q5",
"type": "single_choice",
"title": "你在展馆里更喜欢?",
"options": [
{ "id": "Q5_A", "text": "和人群一起体验互动装置" },
{ "id": "Q5_B", "text": "安静地看介绍和展品细节" }
],
"scoring": {
"mode": "psych_by_option",
"config": {
"weights": {
"Q5_A": { "total": 2, "E": 2, "I": 0 },
"Q5_B": { "total": 2, "E": 0, "I": 2 }
}
}
}
}
E/I是心理维度,total是总分(可选)。
3.4 结果规则:按总分 & 维度给结果 / 奖励
"result_rules": [
{
"when": { "kind": "total_score_gte", "min": 20 },
"then": {
"tag": "pass",
"title": "通关成功",
"text": "恭喜你完成展馆挑战!",
"reward": { "points": 50, "lottery_chance": 1 }
}
},
{
"when": { "kind": "dimension_gte", "dim": "E", "min": 5 },
"then": {
"tag": "extrovert",
"title": "你是外向型观众",
"text": "适合多参与互动区域。",
"reward": { "badge": "extrovert_visitor" }
}
},
{
"when": { "kind": "default" },
"then": {
"tag": "normal",
"title": "感谢参与",
"text": "欢迎再次光临。",
"reward": { "points": 10 }
}
}
]
4. Go 侧数据结构设计(schema 尽量薄)
4.1 配置 struct
package quiz
import "encoding/json"
type QuizConfig struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Config map[string]interface{} `json:"config,omitempty"`
Questions []Question `json:"questions"`
ResultRules []ResultRule `json:"result_rules,omitempty"`
}
type Question struct {
ID string `json:"id"`
Type string `json:"type"` // single_choice / multiple_choice / true_false / text
Title string `json:"title"`
Options []Option `json:"options,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
Scoring ScoringRule `json:"scoring"`
}
type Option struct {
ID string `json:"id"`
Text string `json:"text"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
type ScoringRule struct {
Mode string `json:"mode"` // standard_choice / text_pattern / psych_by_option / ...
Config json.RawMessage `json:"config,omitempty"` // 各模式自己解析
}
// 结果规则(条件 + 结果)
type ResultRule struct {
When map[string]interface{} `json:"when"`
Then map[string]interface{} `json:"then"`
}
只有
ScoringRule.Mode是硬编码的;对应Config用json.RawMessage,需要时按不同模式解析。
4.2 各种计分模式的内部 config(可选解析)
// 标准选择题
type StandardChoiceConfig struct {
CorrectOptionIDs []string `json:"correct_option_ids"`
ScoreIfCorrect int `json:"score_if_correct"`
ScoreIfWrong int `json:"score_if_wrong"`
Partial bool `json:"partial,omitempty"`
PerOptionScore int `json:"per_option_score,omitempty"`
WrongPenalty int `json:"wrong_penalty,omitempty"`
}
// 填空 / 文本题
type TextPatternConfig struct {
Patterns []struct {
Match string `json:"match"` // equals / regex / contains ...
Value string `json:"value"`
Score int `json:"score"`
} `json:"patterns"`
DefaultScore int `json:"default_score"`
}
// 心理测验:按选项加分
type PsychByOptionConfig struct {
Weights map[string]map[string]int `json:"weights"` // option_id -> { "total": 2, "E": 2, "I": 0 }
}
这些 struct 只是「内部工具」,不影响整体 schema。你以后要加新模式,就多写一个 config struct + 打分函数。
5. 打分引擎(核心逻辑示意)
5.1 用户答案 & 分数结构
type UserAnswer struct {
QuestionID string `json:"question_id"`
SelectedOptionIDs []string `json:"selected_option_ids,omitempty"`
AnswerText string `json:"answer_text,omitempty"`
}
type ScoreSummary struct {
Total int `json:"total"`
Dimensions map[string]int `json:"dimensions"`
PerQuestion map[string]int `json:"per_question,omitempty"`
}
func newScoreSummary() ScoreSummary {
return ScoreSummary{
Dimensions: make(map[string]int),
PerQuestion: make(map[string]int),
}
}
5.2 打分入口
func (q *QuizConfig) Evaluate(answers []UserAnswer) (ScoreSummary, error) {
ansMap := make(map[string]UserAnswer, len(answers))
for _, a := range answers {
ansMap[a.QuestionID] = a
}
sum := newScoreSummary()
for _, question := range q.Questions {
userAns, ok := ansMap[question.ID]
if !ok {
continue
}
score, dims, err := scoreOneQuestion(question, userAns)
if err != nil {
return sum, err
}
sum.Total += score
sum.PerQuestion[question.ID] = score
for k, v := range dims {
sum.Dimensions[k] += v
}
}
return sum, nil
}
5.3 针对不同 mode 分发打分
func scoreOneQuestion(q Question, ans UserAnswer) (int, map[string]int, error) {
dims := map[string]int{}
switch q.Scoring.Mode {
case "standard_choice":
var cfg StandardChoiceConfig
if err := json.Unmarshal(q.Scoring.Config, &cfg); err != nil {
return 0, nil, err
}
score := scoreStandardChoice(cfg, ans.SelectedOptionIDs)
return score, dims, nil
case "text_pattern":
var cfg TextPatternConfig
if err := json.Unmarshal(q.Scoring.Config, &cfg); err != nil {
return 0, nil, err
}
score := scoreTextPattern(cfg, ans.AnswerText)
return score, dims, nil
case "psych_by_option":
var cfg PsychByOptionConfig
if err := json.Unmarshal(q.Scoring.Config, &cfg); err != nil {
return 0, nil, err
}
score, dims2 := scorePsychByOption(cfg, ans.SelectedOptionIDs)
for k, v := range dims2 {
dims[k] += v
}
return score, dims, nil
default:
// 未知模式:当作 0 分
return 0, dims, nil
}
}
(内部 scoreStandardChoice / scoreTextPattern / scorePsychByOption 就按自己的业务逻辑实现即可。)
6. 前后端接口与“隐藏答案”
- DB / 内部使用的 JSON = 上面完整结构(含
scoring)。 - 返回给前端的 JSON,可以用一个转换方法去掉计分信息:
type PublicQuestion struct {
ID string `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Options []Option `json:"options,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
type PublicQuiz struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Config map[string]interface{} `json:"config,omitempty"`
Questions []PublicQuestion `json:"questions"`
}
func (q *QuizConfig) ToPublic() PublicQuiz {
p := PublicQuiz{
ID: q.ID,
Title: q.Title,
Type: q.Type,
Config: q.Config,
}
for _, qq := range q.Questions {
p.Questions = append(p.Questions, PublicQuestion{
ID: qq.ID,
Type: qq.Type,
Title: qq.Title,
Options: qq.Options,
Meta: qq.Meta,
})
}
return p
}
-
GET /api/v1/quiz/:id返回PublicQuiz。 -
POST /api/v1/quiz/:id/submit:- 请求:
[]UserAnswer - 后端:加载
QuizConfig→Evaluate()→ 根据result_rules选结果 → 返回:
- 请求:
{
"score": {
"total": 23,
"dimensions": { "E": 5, "I": 3 }
},
"result": {
"tag": "extrovert",
"title": "你是外向型观众",
"text": "适合多参与互动区域。",
"reward": { "points": 50 }
}
}
7. 这套方案的灵活性在哪里
- 题型扩展:以后加
rating、排序题,只要type+ 新的mode+ 新的config即可。 - 计分扩展:某些展览要搞特别玩法,就新定义一个
mode,在后端实现一小段打分函数、定义自己的config约定,不需要改 DB / JSON 顶层结构。 - 心理测验&标准题共存:同一套问卷里,部分题用
standard_choice,部分用psych_by_option,统一打分。 - 配置驱动:实际业务大部分通过改 JSON 配置完成,不需要频繁改代码。