数字无极小程序问答设计

1. 需求 一个展览 = 一个或多个问答流程(这里先按一个流程来设计,可扩展多流程)。 每个流程有多道题,题型包括: 单选题 single_choice 多选题 multiple_choice 判断题 true_false(本质是特殊单选) 填空题 / 简单文本题 text 答案形式要支持:

1. 需求

  1. 一个展览 = 一个或多个问答流程(这里先按一个流程来设计,可扩展多流程)。

  2. 每个流程有多道题,题型包括:

    • 单选题 single_choice
    • 多选题 multiple_choice
    • 判断题 true_false(本质是特殊单选)
    • 填空题 / 简单文本题 text
  3. 答案形式要支持:

    • 标准对/错:答对给分,答错不给分 / 扣分。
    • 心理测验式:每个选项对应不同分数(甚至是多维度分数),没有“对错”,只有“倾向”。
  4. 希望:

    • 结构简单/通用,以后扩展题型/计分方式只改 JSON,不大改 schema。
    • 前端拿到的是不带正确答案的版本
    • 后端有一个统一的打分引擎

2. 整体技术方案概览

2.1 核心思路

  • DB 里存的就是一份 JSON 配置(quiz_config),里面包含:

    • 问题列表(题干 + 选项)
    • 每题的计分规则(不直接暴露给前端
    • 结果规则(总分 / 维度分 → 文案 + 奖励)
  • Go 后端:

    • 用很薄的 struct 描一下 JSON 的形状,复杂字段一律 json.RawMessagemap[string]any
    • 写一个统一的 Evaluate() 打分函数,根据 scoring.mode 分发。
  • 请求/响应:

    • 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 是硬编码的;对应 Configjson.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
    • 后端:加载 QuizConfigEvaluate() → 根据 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 配置完成,不需要频繁改代码。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页