English

Quiz App — Design

A standalone, mobile-first Astro app for cloud-certification exam simulation and clue-assisted practice. Server-owned attempts and deadlines make sessions resumable and tamper-resistant, while a git-backed CSV question bank with per-row content hashing gives idempotent imports and immutable attempt history from day one.

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

概述

构建一个独立的、移动优先的 Astro 产品,用于真实的云认证考试模拟和带线索提示的练习。服务端拥有每一个答案和倒计时截止时间,而题库内容则由 git 中经过 PR 审核的 CSV 文件掌管。这带来了可恢复的会话、持久的历史记录,以及通往统计功能的清晰路径,同时不需要引入客户端框架,也不需要让 Gremlin 的后端参与其中。

  • 位置frontend/astro/apps/quiz
  • 产品边界独立的应用、数据库、认证与部署
  • 核心模式限时考试 + 带线索的不限时练习
  • 内容权威来源git 中经 PR 审核的 CSV,每种考试类型一个文件
  • 默认考试65 题 · 90 分钟
  • 状态已批准,可进入实施
已选择

服务端渲染表单

状态归属
Postgres
恢复方式
重新打开考试即可
截止时间
服务端强制执行

每个答案都是一次表单 POST,随后是 303 跳转。刷新页面、关闭标签页、手机锁屏都不会丢失进度。

已否决

客户端考试引擎

状态归属
重复存储
恢复方式
需要同步机制
截止时间
仍然需要服务端

客户端框架会重复保存状态,却无法改善这种「导航 + 表单」的交互模型。

架构

测验应用是一个自包含的服务端产品;与 Gremlin 平台只共享 pnpm workspace。
测验应用是一个自包含的服务端产品;与 Gremlin 平台只共享 pnpm workspace。
关注点选择
适配器@astrojs/node,配合 output: 'server'
渲染方式服务端整页渲染;无客户端框架
数据库在现有 Postgres 实例上新建一个数据库
查询层pg 连接池配合手写参数化 SQL;不使用 ORM
认证Better Auth 使用同一个连接池;数据库 ID 固定为 UUID
认证邮件通过 nodemailer 走 SMTP,用于验证和重置密码
样式Tailwind CSS v4,通过 @tailwindcss/vite
内容格式CSV,用 csv-parse/sync 解析;zod 在任何写入前完成校验
Markdownmarked 在导入时一次性渲染那三个 Markdown 列

运行时依赖为 astro@astrojs/nodetailwindcss@tailwindcss/vitebetter-authpgzodmarkednodemailercsv-parse 属于开发依赖,因为只有导入脚本用到它,它从不在服务进程中运行。开发依赖还包括 vitestplaywright、必要的类型包,以及版本与运行时包完全一致的 Better Auth CLI;这两个 Better Auth 版本必须同步升级。

数据模型

Better Auth 固定版本的 CLI 拥有 usersessionaccountverification 四张表。应用自己拥有四张表。所有主键都是 uuid,由 gen_random_uuid() 生成;所有时间字段都是 timestamptz

Question(题目)

类型含义
iduuid PK生成的主键
slugtext UNIQUE NOT NULL来自 CSV Slug 列的手写导入标识,例如 saa-c03-athena-s3-logs
exam_typetext NOT NULL取自 CSV 文件名主干:saa-c03gcp-ace
bodytext NOT NULL题干纯文本,输出时转义
explanation_md / explanation_htmltext NOT NULL为什么正确答案是对的
study_note_md / study_note_htmltext NULL可选的备注、技巧或学习指南
tagstext[] NOT NULL DEFAULT '{}'带 GIN 索引的标签,例如 {athena,s3,sql}
community_vote_labeltext NULL社区倾向的答案标签,例如 AAB
community_pctint NULL社区认同度 0–100,不含 % 存储
pdf_suggested_labeltext NULL官方答案册给出的标签
content_hashtext NOT NULL源数据行的 SHA-256;驱动导入变更检测
retired_attimestamptz NULL为 NULL 时可进入新的考试/练习
created_at / updated_attimestamptz NOT NULL DEFAULT now()生命周期时间戳

索引:slug 唯一索引、exam_type btree 索引、tags GIN 索引。稳定的 slug 让改错别字之后旧的作答记录仍然挂在同一题上。用数组代替标签表加关联表:WHERE tags @> ARRAY['athena'] 走 GIN 索引,SELECT DISTINCT unnest(tags) 提供筛选项。拼写错误由 PR 审核来把关。

Answer option(选项)

类型含义
iduuid PK稳定的选项标识
question_iduuid FK → question,级联删除所属题目
labeltext NOT NULLAE
bodytext NOT NULL选项纯文本,输出时转义
is_correctboolean NOT NULL同时支持单选和多选正确答案
why_wrong_md / why_wrong_htmltext NULL错误选项必填,正确选项禁止填写
positionint NOT NULL显示顺序

约束:(question_id, label) 唯一、(question_id, position) 唯一,以及一条 CHECK:当选项正确时两个 why-wrong 字段必须同时为 NULL。把正确性放在每个选项上,让「选两个」和单选题共用同一套 schema;把「为什么错」放在每个选项上,让反馈能显示在对应选项旁边。

Exam(考试)

类型含义
iduuid PK考试标识
user_iduuid FK → "user"(id),级联删除归属用户
exam_typetext NOT NULL所选认证类型
question_countint NOT NULL实际抽取的题目数
duration_minutesint NOT NULL配置的时长
started_attimestamptz DEFAULT now()数据库记录的开始时间
deadline_attimestamptz NOT NULL不可变的计时权威
submitted_attimestamptz NULL为 NULL 表示进行中;没有 status 列

约束与索引:(id, user_id) 唯一,用于支撑保留归属关系的 attempt 外键;题目数 1–200;时长 1–480;deadline_at > started_at(user_id) WHERE submitted_at IS NULL 唯一索引保证每人只有一场进行中的考试;(user_id, submitted_at) 索引支撑历史查询与恢复。

Attempt(作答)

类型含义
iduuid PK一道「发到你手上的题」
user_iduuid FK → "user"(id),级联删除归属用户
question_iduuid FK → question,限制删除保证历史题目仍可访问
exam_iduuid NULL为 NULL 表示练习模式;在外键中与 user 配对
positionint NULL考试中必填,练习中为 NULL
selected_option_idsuuid[] NOT NULL DEFAULT '{}'提交的答案集合
revealed_option_idsuuid[] NOT NULL DEFAULT '{}'仅练习模式:被线索排除的选项
is_correctboolean NULL未作答时为 NULL
answered_attimestamptz NULL与正确性成对出现
created_attimestamptz DEFAULT now()记录创建时间

约束:(exam_id, user_id) REFERENCES exam(id, user_id) ON DELETE CASCADE;position 恰好在考试作答时存在;answered_atis_correct 要么同时为 NULL,要么同时有值;考试作答不能包含被揭示的选项。索引:(user_id, question_id)exam_id 非空时的考试位置唯一索引;以及每个 (user_id, question_id) 只允许一条未作答的练习记录。

考试题单

创建时

N 条未作答记录固定了抽中的题目和顺序。

恢复

持久

每次作答立即更新;重新打开就能还原确切进度。

练习历史

只追加

exam_id IS NULL;「再试一次」是插入新记录而不是覆盖。

统计

面向未来

聚合已作答记录;cardinality(revealed_option_ids) = 0 可筛出「无提示答对」。

被放弃的练习页面加载永远不会算作答错。考试提交时会显式把空白题单记录标为错误。因此在统计页面存在之前,按用户、考试类型、题目和标签统计所需的数据就已经在积累了。

题库内容与导入

谁拥有题库内容?

git 中用电子表格编写的 CSV 文件:apps/quiz/content/saa-c03.csvapps/quiz/content/gcp-ace.csv,以后每种考试类型一个文件。文件名主干就是 exam_type——没有任何列或清单文件承载它。

数据库中的题目和选项行是派生投影。PR 合并权限就是内容编辑的权限模型,所以运行时不需要角色列、编辑权限或后台管理界面。这里 CSV 胜过 JSON,因为题库是在电子表格里编写和审阅的,一行一题远比嵌套 JSON 更容易浏览。

代价是 CSV 没有类型也没有嵌套,所以有几列要按约定解析并严格校验。而 userexamattempt 这些运营数据永远不可能从 git 重建。

CSV 列映射

一行表头,之后一行一题。表头名称必须完全一致;多出、缺失或拼错的列会让导入失败,而不是被悄悄忽略——因为在电子表格里犯这种错太容易了。

是否必填映射到
Slug必填question.slug
ID忽略行号;从不导入
Q#忽略恒等于 ID + 1;从不导入
Question必填question.body(纯文本)
A B C D必填各对应一条 answer_option
E可选非空时生成第五条 answer_option
My Answer必填决定 answer_option.is_correct
Community Vote可选question.community_vote_label
Community %可选question.community_pct,整数:97%97
PDF Suggested可选question.pdf_suggested_label
Flag忽略完全可由上面两列推导;从不存储
Why Correct必填question.explanation_md
Why Wrong必填拆分到各个错误 answer_option
Study Note可选question.study_note_md
Tags可选question.tags,分号分隔

Slug 是每行一个的简短手写标识,例如 saa-c03-serverless-flash-sale-site,需匹配 ^[a-z0-9-]+$ 并在所有文件中唯一。My Answer 就是正确答案——这是你自己复核后的结论,也是应用判分的依据。Community VotePDF Suggested 只作为出处记录。这三列格式相同:一个或多个字母标签,中间没有分隔符(ACAB),读取时不区分大小写和顺序。

Tags 会被去空格、转小写,并把内部空格换成连字符,于是 S3; Transfer Acceleration 变成 {s3, transfer-acceleration}——URL 安全且大小写统一。空片段会被丢弃。

Why Wrong 的解析

Why Wrong 把所有错误选项的解释打包在一个单元格里,形式是 Markdown 无序列表,一行一条:

- **A:** S3 can only serve files. It cannot run the code that takes an order, and S3 is not a database.
- **B:** EC2 instances, two load balancers, and an RDS database all have to be sized, patched, and monitored by you.
- **C:** Running Kubernetes on EKS means you manage a cluster, node groups, and pod scaling.
  • line_pattern每一非空行都需匹配 ^- \*\*([A-E]):\*\*\s+(.+)$严格
  • label_set必须与「存在且不正确」的选项集合完全一致——不多不少精确
  • no_continuation一行一条;折行的续行与格式错误的条目无法区分1
  • verified在当前 40 行中解析出的条目数,零异常120

不匹配的行会被判为校验错误,绝不跳过。标签之后捕获到的文本存入该选项的 why_wrong_md- **A:** 前缀由解析器消耗掉,不会被存储,因为界面会把每条解释显示在它对应的选项旁边,否则标签就重复了。

解析器以 columns: truebom: trueskip_empty_lines: true 以及严格列数运行。bom: true 很关键:电子表格经常写入 UTF-8 字节序标记,否则会破坏第一个表头名。

导入流程

scripts/import.tspnpm run importpnpm run import --dry-run 运行。它始终读取整个 content/ 目录——没有单文件模式,因为只有拿到完整的源集合才能判定哪些题目该退役。

  1. 解析每个 CSV

    读取 content/ 下所有 *.csv 文件。文件名主干为该文件中每一行设定 exam_type

  2. 写入前先校验

    Zod 检查表头名称完全一致;Slug 存在、格式正确且全局唯一;AD 非空而 E 可选;My Answer 只引用存在的选项且绝不选中全部选项;Why Wrong 各行匹配列表格式且标签集合精确对应;Community % 在 0–100 之间;Community VotePDF Suggested 只引用存在的选项。至少要有一个文件和一行数据,因此空目录不可能让整个题库退役。

  3. 渲染 Markdown

    marked 渲染那三个 Markdown 字段;题干和选项正文按纯文本存储。

  4. 按内容哈希分类

    为每一行计算 content_hash,并把每个 slug 归类为新增、变更、未变、恢复或退役。

  5. 在单个事务中写入

    锁定 exam 阻止新插入,结算已过期的考试,若仍有进行中的考试则中止并报告其 ID。按 slug upsert 新增、变更和恢复的题目并置 retired_at = NULL;按 (question_id, label) upsert 选项,使措辞和顺序调整都能保留选项 ID;只删除该题源数据中已消失的标签;最后为源集合中不再出现的原活跃题目设置 retired_at

变更检测

每一行 question 都带有一个 SHA-256 的 content_hash,它覆盖所有来自源数据的值的规范化序列:slug、考试类型、题干、解释、学习笔记、排序后的标签、出处列,以及每个选项按标签排序后的标签、正文、正确性和错误说明。任何会改变数据库行的内容都会改变哈希;其他任何东西都不会。这就把「哪些已导入、哪些还没有」简化成每个 slug 一次比较。

分类条件动作
新增数据库中没有该 slug插入题目和选项
变更slug 存在,哈希不同更新题目和选项
未变slug 存在,哈希相同完全跳过——不写入
恢复slug 存在且 retired_at 有值清空 retired_at,随后按「变更」处理
退役活跃的 slug 在源集合中消失设置 retired_at;绝不删除

跳过未变的行,是这套机制在文件不断增长后仍然好用的关键。它也让 question.updated_at 保持诚实:只有内容真正变动时它才会更新,因此它是真正的「最后编辑时间」,而不是「最后一次有人跑过导入脚本的时间」。导入脚本会打印汇总,并在校验失败时以非零状态退出:

saa-c03.csv    40 rows    2 new   3 changed   35 unchanged
gcp-ace.csv    25 rows   25 new   0 changed    0 unchanged
retired:        1 (saa-c03-old-question)

--dry-run 会执行包括分类和汇总在内的每一步,然后回滚事务。在电子表格改动真正生效之前,这是查看它会造成什么后果的安全方式。哈希比较加上按 slug 的 upsert 让导入具备幂等性:跑多少次都收敛到同一状态,而且 question.id 永不改变,所有已有的 attempt 记录都仍然挂在原题上。

从 git 中整题删除会让它退役而不是被删掉。退役题目不会进入新的考试、练习列表、筛选和随机抽取,但在历史结果中仍可阅读。重新加回同一个 slug 会清除退役标记并保留其身份。

迁移顺序

测验迁移是 apps/quiz/migrations/ 下按文件名排序的 SQL,由 scripts/migrate.ts 应用,并记录在 schema_migration(version, applied_at) 中。Better Auth 的表来自仓库中固定版本的 CLI,绝不用浮动的 pnpm dlx ...@latest

  1. 在现有 Postgres 实例上创建专用的测验数据库。
  2. 启用 pgcrypto 以支持 gen_random_uuid()
  3. 以非交互方式运行固定版本的 Better Auth CLI 迁移。
  4. 断言 "user".id 的 PostgreSQL 类型是 uuid
  5. 运行 scripts/migrate.ts,然后运行 scripts/import.ts

pnpm run setup:db 把这个顺序串起来,这样应用的外键绝不会先于它所引用的认证 schema 出现。

认证与请求安全

export const auth = betterAuth({
  database: pool,
  advanced: { database: { generateId: 'uuid' } },
  emailAndPassword: {
    enabled: true,
    requireEmailVerification: true,
    sendResetPassword,
  },
  emailVerification: { sendVerificationEmail, sendOnSignUp: true },
  databaseHooks: { user: { create: { before: assertEmailAllowed } } },
});

准入资格

白名单

QUIZ_ALLOWED_EMAILS 在启动时统一去空格并转小写。为空或缺失则拒绝所有注册。

所有权证明

邮箱验证

进入白名单并不等于证明拥有邮箱。未完成验证前禁止登录;重置邮件可找回被抢注或遗忘的凭据。

配置

快速失败

认证密钥与 SMTP 在启动时校验。BETTER_AUTH_SECRET 至少 32 个字符。

生产加固

内置

启用安全 Cookie 和 Better Auth 内置限流。只有在显式配置了代理链时才信任代理 IP 头。

验证和重置回调都是由 BETTER_AUTH_URL 推导出的绝对 URL;绝不用请求头去猜测公开来源。src/pages/api/auth/[...all].ts 处的通配端点导出 export const ALL: APIRoute = ({ request }) => auth.handler(request);

src/middleware.ts 把会话解析到 locals.user,并把未登录用户重定向到 /sign-in。公开路径为 /sign-in/sign-up/verify-email/forgot-password/reset-password/api/auth/* 和静态资源。未验证的用户无法进入受保护页面。

Better Auth 保护它自己的 /api/auth/* 变更接口。应用层授权只看归属:用户只能读写自己的考试和作答;所有登录用户都能读取题目;运行时没有人能写入题目。他人的考试 ID 返回 404,而不是用 403 变相确认其存在。

应用结构

frontend/astro/apps/quiz/
├── content/
│   ├── saa-c03.csv
│   └── gcp-ace.csv
├── migrations/
│   └── 001_quiz.sql
├── scripts/
│   ├── migrate.ts
│   └── import.ts
└── src/
    ├── lib/
    │   ├── auth.ts          Better Auth 服务端 + 白名单钩子
    │   ├── config.ts        快速失败的环境变量解析与数值边界
    │   ├── db.ts            pg 连接池单例
    │   ├── email.ts         SMTP 验证与重置邮件
    │   ├── queries.ts       所有 SQL,每个查询一个导出函数
    │   ├── scoring.ts       纯函数:答案集合判分
    │   └── clues.ts         纯函数:线索上限与下一个揭示项
    ├── middleware.ts
    ├── layouts/QuizLayout.astro
    ├── components/
    │   ├── QuestionBody.astro
    │   ├── OptionList.astro
    │   ├── ExplanationPanel.astro
    │   └── ExamTimer.astro
    └── pages/
        ├── index.astro
        ├── sign-in.astro
        ├── sign-up.astro
        ├── verify-email.astro
        ├── forgot-password.astro
        ├── reset-password.astro
        ├── api/auth/[...all].ts
        ├── exam/new.astro
        ├── exam/[id]/[position].astro
        ├── exam/[id]/result.astro
        ├── practice/index.astro
        └── practice/[slug].astro

scoring.tsclues.ts 保持纯函数且不访问数据库。queries.ts 是唯一编写 SQL 的模块;页面调用具名查询函数。src/lib/config.ts 只解析一次环境变量,因此页面代码从不直接读取 process.env,也不会自行编造兜底上限。

考试模式

创建考试

GET /exam/new 渲染考试类型、题目数量和时长。默认值为 QUIZ_DEFAULT_QUESTION_COUNT=65QUIZ_DEFAULT_DURATION_MINUTES=90,但每场考试都可在 1–200 题、1–480 分钟范围内覆盖。

  1. 解决进行中的考试

    在一个使用数据库时钟的事务中,先结算该用户已过期的考试。若仍有进行中的考试,则以 303 跳转到它第一道未作答的题。部分唯一索引会让并发创建收敛到同一场考试。

  2. 抽取题单

    执行 SELECT id FROM question WHERE exam_type = $1 AND retired_at IS NULL ORDER BY random() LIMIT $2。若一道题也没有,则重新渲染表单且不插入任何数据。

  3. 写入截止时间

    插入 exam,题目数取实际抽中的数量,deadline_at = transaction_timestamp() + duration_minutes。题库较小则生成一场较短但依然有效的考试,并在结果页说明。

  4. 固定顺序

    在位置 1..N 插入未作答的 attempt 记录,然后以 303 跳转到 /exam/:id/1

作答

GET /exam/:id/:position 按顺序检查:非本人所有 → 404;已提交 → 结果页;数据库 now() >= deadline_at → 先结算再进结果页;位置非法 → 跳到第一道未作答的题。页面显示一道题、一个倒计时,以及标注作答状态的编号网格。恰好一个正确选项时渲染单选框,多个正确选项时渲染复选框。考试进行中,页面上不会出现线索、答案、解释或学习笔记。

POST 会在事务中重复检查归属、提交状态、截止时间和位置,同时用 SELECT ... FOR UPDATE 锁住考试行。它对提交的 ID 去重,拒绝不属于该题的 ID,写入选择、正确性和作答时间,然后跳转到下一道未作答的题。若已无剩余,则提交并跳转到结果页。只有在考试仍进行中且还有其他位置未作答时,已作答的记录才可以被覆盖。

判分与结果

判分是全对才得分:归一化后的所选选项 ID 集合必须与正确选项 ID 集合完全相等。「选两个」只对一半、多选了、以及空白作答都得零分。

GET /exam/:id/result 显示正确数/总数、百分比、用时,以及按顺序排列的每道题,包含你的答案、正确答案、解释、每个错误选项的错误说明、学习笔记和标签。已过截止时间但仍进行中的考试会先结算;仍在进行中的考试会跳回第一道未作答的题。结果在提交前绝不暴露。

练习模式

GET /practice 列出未退役的题目,可按考试类型和标签筛选,为每题标注用户最近一次的作答结果,并提供在当前筛选范围内随机抽题。

当前作答

GET /practice/:slug 加载唯一那条未作答的练习记录,不存在时用 INSERT ... ON CONFLICT DO NOTHING 创建,然后选出胜出的那一行。在页面加载时创建记录,使得线索状态能在刷新后保留。已退役的 slug 只能通过历史记录阅读,无法开始新的作答。

渐进式线索

  • clue_limitmin(QUIZ_MAX_CLUES, wrongOptionCount − 1)N−1
  • four_option_single3 个错误选项;始终保留 2 个可选2
  • five_option_choose_two3 个错误选项;始终保留 2 个可选2
  1. SELECT ... FOR UPDATE 锁住当前作答记录。
  2. 若已揭示数量达到上限,则不做任何事并以 303 跳回;此时按钮本来就已隐藏。
  3. 否则随机挑一个尚未揭示的错误选项,追加到 revealed_option_ids,然后 303 跳回。

加锁让连续快速的线索请求串行化,因此它们不会重复揭示同一个选项、超出上限或互相覆盖更新。被揭示的选项会变灰并加删除线,显示其错误说明,且不可被选中。−1 这个上限保证线索永远不会直接把答案送到手上。

作答与重试

action=answer 锁住同一条记录,对 ID 归一化,拒绝不属于该题的选项 ID 以及任何已被排除的选项,然后存储选择、正确性和作答时间。重放该 POST 无法覆盖已作答的记录。

借助线索答对仍然算作答对;已揭示选项的数量保留了日后区分「独立答对」与「借助提示答对」的可能。「再试一次」会通过同样的防冲突路径创建一条全新的未作答记录,因此练习历史会不断累积。

计时器

数据库时钟强制执行不可变的截止时间;浏览器倒计时只负责展示。
数据库时钟强制执行不可变的截止时间;浏览器倒计时只负责展示。

exam.deadline_at 只写入一次,永不修改。每一次考试相关的 GET 和 POST 都会在考试行锁下比对数据库时钟。约十行的客户端脚本读取一个 data- 属性,每秒更新一次,归零时跳转到结果页。关闭标签页、手机休眠、暂停 JavaScript 或编辑页面都无法增加时间,因为服务端从不参考客户端的时钟。

错误处理

场景行为
他人的或不存在的考试 ID404
位置超出范围跳到第一道未作答的题;若已无剩余则结算并显示结果
考试已提交跳转到结果页
截止时间之后的请求自动提交并跳转到结果页
提交前请求结果跳到第一道未作答的题
线索次数已达上限忽略并跳回,不报错
在考试模式请求线索路由不存在,按钮也从不渲染
未选择任何选项就提交接受,判为错误
选项不属于该题400;不做任何变更
选项已被线索排除400;不做任何变更
应用 POST 的 Origin 缺失或不符403;不做任何变更
导入校验失败不写入数据库;指明文件、slug 和字段
不在白名单内的注册统一提示「该邮箱地址暂不支持注册」
未验证邮箱就登录拒绝,并提供重新发送验证邮件
该考试类型没有题目重新渲染表单;不创建任何数据
题数或时长非法重新渲染并给出带边界值的校验提示

配置

变量用途
QUIZ_DATABASE_URL专用测验 Postgres 连接串
BETTER_AUTH_SECRET会话签名密钥;至少 32 个字符
BETTER_AUTH_URL规范的公开基础 URL,同时是 Origin 权威
QUIZ_ALLOWED_EMAILS逗号分隔的注册白名单;为空则全部拒绝
QUIZ_DEFAULT_QUESTION_COUNT默认 65;每场考试可改;范围 1–200
QUIZ_DEFAULT_DURATION_MINUTES默认 90;每场考试可改;范围 1–480
QUIZ_MAX_CLUES默认 3;在「错误数减一」上限之前的非负上界
QUIZ_SMTP_HOST / QUIZ_SMTP_PORTSMTP 服务端点
QUIZ_SMTP_USER / QUIZ_SMTP_PASSWORDSMTP 凭据
QUIZ_EMAIL_FROM已验证的认证邮件发件人

配置在启动时校验一次,默认值也必须满足与提交值相同的边界。生产环境配置非法或不完整时服务会直接停止,而不是悄悄削弱认证或改变考试规则。

测试与验收标准

单元测试 · Vitest · 不依赖数据库

  • scoring.ts:单选的成功与失败;「选两个」的完全正确、部分正确和多选情形;空白作答。
  • clues.tsmin(3, wrong − 1);四选项和五选项的代表性用例都得出 2;绝不挑中正确选项,也绝不重复已揭示的选项。
  • 截止时间比较:在 deadline_at 之前、恰好等于、之后三种情况。
  • 导入校验会拒绝重复的 slug、不符合 ^[a-z0-9-]+$ 的 slug、缺失或拼错的表头列、空的 My Answer、指向不存在选项或选中全部选项的 My Answer、超过 100 的 Community %,以及 D 为空却填了 E 的情形。
  • Why Wrong 解析能把 - **B:** … 列表拆到正确的选项上并剥掉标签前缀;会拒绝缺失的标签、指向正确选项的多余标签、不匹配的行以及折行的续行。对真实 40 行中的全部 120 个条目往返解析,零异常。
  • 答案标签解析把 AABbaA B 归一化为同一集合;拒绝 AAAF
  • 标签归一化把 S3; Transfer Acceleration; 变成 {s3, transfer-acceleration}
  • Markdown 范围:explanationstudy_notewhy_wrong 渲染成 HTML;题干和选项正文不经渲染直接透传,并在输出时转义。
  • content_hash 对相同输入保持一致,重排标签或选项不会改变它;任何被导入字段变动都会改变它,而被忽略的列(IDQ#Flag)变动则永远不会。
  • 提交选项校验会拒绝外来的、重复的和已被揭示的 ID;判分使用归一化后的集合。

集成测试 · 真实 Postgres

  • 导入两次而行数不变;就地更新一条解释;保持 question.id 稳定且作答记录仍可解析。
  • 文件未改动时的第二次导入把每一行都归类为未变、不产生任何写入,且所有 updated_at 保持原样。
  • 改动一个单元格后,恰好那一行被归类为变更,其余全部为未变。
  • content/ 中的两个文件导入为两种考试类型;新增第二个文件不会改动第一个文件的行,也不会让它们退役。
  • --dry-run 给出与真实运行相同的分类结果,且不提交任何内容。
  • 空操作导入在考试进行期间也能成功;带真实变更的导入会中止并指明进行中的考试。
  • 删除一个选项时保留其余选项 ID;历史选择显示「选项已移除」占位内容。
  • 删除、退役、排除、在历史结果中渲染,再以相同 ID 重新加回一道题。
  • 创建考试恰好插入 N 条位于 1..N 的 attempt 记录。
  • 并发的练习页面加载只产生一条未作答记录;并发线索请求无法超出上限。
  • 截止时间的 POST 与结果页 GET 竞争时,只产生一场已提交的考试且不会写入迟到的答案。
  • 生成的 Better Auth schema 在应用迁移之前就把 "user".id 暴露为 PostgreSQL uuid

端到端测试 · Playwright

  • 登录、开始考试、作答两题、刷新、恢复选择与正确的剩余时间、提交,并核对得分。
  • 在截止时间之后打开考试会自动提交。
  • 跨域的应用 POST 被拒绝且不产生任何变更。
  • 用完所有练习线索,观察按钮消失,作答,然后看到所有错误选项的错误说明,包括从未被揭示的那些。
  • 拒绝白名单外的注册;阻止白名单用户在验证前登录;完成一次密码重置。

v1 范围之外

不可变题目版本——规划中的第二阶段

v1 在任何考试进行期间都拒绝导入。这样做是安全的,但它假设存在一个维护窗口。一旦多个人全天都在考试,可能根本没有「没人在考试」的时刻,题库也就无法更新。解决办法是不再原地修改题目:每次导入都创建一个新的不可变版本,而每场考试都固定在它开始时所用的那个版本上。

  1. Alice 用题目 10 的版本 1 开始考试。
  2. 一次导入创建了题目 10 的版本 2。
  3. Alice 进行中的考试继续显示并按版本 1 判分。
  4. 导入之后开始的考试使用版本 2。
  5. 版本 1 仍可读取,用于 Alice 的成绩和历史复盘。
存放内容
question仅保留永久身份:idslugexam_typeretired_at
question_revision每个版本一行:题干、解释、学习笔记、标签、出处列、content_hashcreated_at
answer_option父表从 question 改挂到 question_revision
attempt新增 question_revision_id,在创建考试题单或开始练习作答时记录

content_hash 不同时,导入脚本插入一条新的 question_revision 而不是更新,并且绝不修改已有版本。读取时,历史记录通过 attempt.question_revision_id 解析,新会话则取最新的未退役版本。

从 v1 迁移: 每一条现有 question 拆成身份加一个版本——它的内容列和 content_hash 移入版本 1,它的 answer_option 行改挂到版本 1,所有现有 attemptquestion_revision_id 回填为该版本。不会丢失任何作答历史,因为选项 ID 是沿用而不是重新生成的。

其他推迟项

收藏

推迟

未来加一张 favourite 表和一个筛选条件。

统计页面

只差界面

作答数据现在就在采集;展示留到以后。

向量嵌入

砍掉

没有语义搜索的使用场景。考试类型和标签筛选已经够用;等有了具体需求再看,backend/go 的 digitalworker 模块里已有 pgvector 先例。

后台编辑界面

不需要

git 和 PR 仍然是内容编写流程。

题目配图

当前无需求

目前的源材料都是纯文字。

考试标记待复查

省略

编号题目网格已经能满足导航需求。

OAuth

省略

已验证的邮箱加密码适合这个小范围的已知用户群。