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.
- ADRs 0054
- Status Approved
概述
构建一个独立的、移动优先的 Astro 产品,用于真实的云认证考试模拟和带线索提示的练习。服务端拥有每一个答案和倒计时截止时间,而题库内容则由 git 中经过 PR 审核的 CSV 文件掌管。这带来了可恢复的会话、持久的历史记录,以及通往统计功能的清晰路径,同时不需要引入客户端框架,也不需要让 Gremlin 的后端参与其中。
服务端渲染表单
- 状态归属
- Postgres
- 恢复方式
- 重新打开考试即可
- 截止时间
- 服务端强制执行
每个答案都是一次表单 POST,随后是 303 跳转。刷新页面、关闭标签页、手机锁屏都不会丢失进度。
客户端考试引擎
- 状态归属
- 重复存储
- 恢复方式
- 需要同步机制
- 截止时间
- 仍然需要服务端
客户端框架会重复保存状态,却无法改善这种「导航 + 表单」的交互模型。
架构
| 关注点 | 选择 |
|---|---|
| 适配器 | @astrojs/node,配合 output: 'server' |
| 渲染方式 | 服务端整页渲染;无客户端框架 |
| 数据库 | 在现有 Postgres 实例上新建一个数据库 |
| 查询层 | pg 连接池配合手写参数化 SQL;不使用 ORM |
| 认证 | Better Auth 使用同一个连接池;数据库 ID 固定为 UUID |
| 认证邮件 | 通过 nodemailer 走 SMTP,用于验证和重置密码 |
| 样式 | Tailwind CSS v4,通过 @tailwindcss/vite |
| 内容格式 | CSV,用 csv-parse/sync 解析;zod 在任何写入前完成校验 |
| Markdown | marked 在导入时一次性渲染那三个 Markdown 列 |
运行时依赖为 astro、@astrojs/node、tailwindcss、@tailwindcss/vite、better-auth、pg、zod、marked 和 nodemailer。csv-parse 属于开发依赖,因为只有导入脚本用到它,它从不在服务进程中运行。开发依赖还包括 vitest、playwright、必要的类型包,以及版本与运行时包完全一致的 Better Auth CLI;这两个 Better Auth 版本必须同步升级。
数据模型
Better Auth 固定版本的 CLI 拥有 user、session、account 和 verification 四张表。应用自己拥有四张表。所有主键都是 uuid,由 gen_random_uuid() 生成;所有时间字段都是 timestamptz。
Question(题目)
| 列 | 类型 | 含义 |
|---|---|---|
id | uuid PK | 生成的主键 |
slug | text UNIQUE NOT NULL | 来自 CSV Slug 列的手写导入标识,例如 saa-c03-athena-s3-logs |
exam_type | text NOT NULL | 取自 CSV 文件名主干:saa-c03、gcp-ace 等 |
body | text NOT NULL | 题干纯文本,输出时转义 |
explanation_md / explanation_html | text NOT NULL | 为什么正确答案是对的 |
study_note_md / study_note_html | text NULL | 可选的备注、技巧或学习指南 |
tags | text[] NOT NULL DEFAULT '{}' | 带 GIN 索引的标签,例如 {athena,s3,sql} |
community_vote_label | text NULL | 社区倾向的答案标签,例如 A、AB |
community_pct | int NULL | 社区认同度 0–100,不含 % 存储 |
pdf_suggested_label | text NULL | 官方答案册给出的标签 |
content_hash | text NOT NULL | 源数据行的 SHA-256;驱动导入变更检测 |
retired_at | timestamptz NULL | 为 NULL 时可进入新的考试/练习 |
created_at / updated_at | timestamptz NOT NULL DEFAULT now() | 生命周期时间戳 |
索引:slug 唯一索引、exam_type btree 索引、tags GIN 索引。稳定的 slug 让改错别字之后旧的作答记录仍然挂在同一题上。用数组代替标签表加关联表:WHERE tags @> ARRAY['athena'] 走 GIN 索引,SELECT DISTINCT unnest(tags) 提供筛选项。拼写错误由 PR 审核来把关。
Answer option(选项)
| 列 | 类型 | 含义 |
|---|---|---|
id | uuid PK | 稳定的选项标识 |
question_id | uuid FK → question,级联删除 | 所属题目 |
label | text NOT NULL | A–E |
body | text NOT NULL | 选项纯文本,输出时转义 |
is_correct | boolean NOT NULL | 同时支持单选和多选正确答案 |
why_wrong_md / why_wrong_html | text NULL | 错误选项必填,正确选项禁止填写 |
position | int NOT NULL | 显示顺序 |
约束:(question_id, label) 唯一、(question_id, position) 唯一,以及一条 CHECK:当选项正确时两个 why-wrong 字段必须同时为 NULL。把正确性放在每个选项上,让「选两个」和单选题共用同一套 schema;把「为什么错」放在每个选项上,让反馈能显示在对应选项旁边。
Exam(考试)
| 列 | 类型 | 含义 |
|---|---|---|
id | uuid PK | 考试标识 |
user_id | uuid FK → "user"(id),级联删除 | 归属用户 |
exam_type | text NOT NULL | 所选认证类型 |
question_count | int NOT NULL | 实际抽取的题目数 |
duration_minutes | int NOT NULL | 配置的时长 |
started_at | timestamptz DEFAULT now() | 数据库记录的开始时间 |
deadline_at | timestamptz NOT NULL | 不可变的计时权威 |
submitted_at | timestamptz 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(作答)
| 列 | 类型 | 含义 |
|---|---|---|
id | uuid PK | 一道「发到你手上的题」 |
user_id | uuid FK → "user"(id),级联删除 | 归属用户 |
question_id | uuid FK → question,限制删除 | 保证历史题目仍可访问 |
exam_id | uuid NULL | 为 NULL 表示练习模式;在外键中与 user 配对 |
position | int NULL | 考试中必填,练习中为 NULL |
selected_option_ids | uuid[] NOT NULL DEFAULT '{}' | 提交的答案集合 |
revealed_option_ids | uuid[] NOT NULL DEFAULT '{}' | 仅练习模式:被线索排除的选项 |
is_correct | boolean NULL | 未作答时为 NULL |
answered_at | timestamptz NULL | 与正确性成对出现 |
created_at | timestamptz DEFAULT now() | 记录创建时间 |
约束:(exam_id, user_id) REFERENCES exam(id, user_id) ON DELETE CASCADE;position 恰好在考试作答时存在;answered_at 与 is_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.csv、apps/quiz/content/gcp-ace.csv,以后每种考试类型一个文件。文件名主干就是 exam_type——没有任何列或清单文件承载它。
数据库中的题目和选项行是派生投影。PR 合并权限就是内容编辑的权限模型,所以运行时不需要角色列、编辑权限或后台管理界面。这里 CSV 胜过 JSON,因为题库是在电子表格里编写和审阅的,一行一题远比嵌套 JSON 更容易浏览。
代价是 CSV 没有类型也没有嵌套,所以有几列要按约定解析并严格校验。而 user、exam 和 attempt 这些运营数据永远不可能从 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 Vote 和 PDF Suggested 只作为出处记录。这三列格式相同:一个或多个字母标签,中间没有分隔符(A、C、AB),读取时不区分大小写和顺序。
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一行一条;折行的续行与格式错误的条目无法区分1verified在当前 40 行中解析出的条目数,零异常120
不匹配的行会被判为校验错误,绝不跳过。标签之后捕获到的文本存入该选项的 why_wrong_md;- **A:** 前缀由解析器消耗掉,不会被存储,因为界面会把每条解释显示在它对应的选项旁边,否则标签就重复了。
解析器以 columns: true、bom: true、skip_empty_lines: true 以及严格列数运行。bom: true 很关键:电子表格经常写入 UTF-8 字节序标记,否则会破坏第一个表头名。
导入流程
scripts/import.ts 以 pnpm run import 或 pnpm run import --dry-run 运行。它始终读取整个 content/ 目录——没有单文件模式,因为只有拿到完整的源集合才能判定哪些题目该退役。
- 解析每个 CSV
读取
content/下所有*.csv文件。文件名主干为该文件中每一行设定exam_type。 - 写入前先校验
Zod 检查表头名称完全一致;
Slug存在、格式正确且全局唯一;A–D非空而E可选;My Answer只引用存在的选项且绝不选中全部选项;Why Wrong各行匹配列表格式且标签集合精确对应;Community %在 0–100 之间;Community Vote与PDF Suggested只引用存在的选项。至少要有一个文件和一行数据,因此空目录不可能让整个题库退役。 - 渲染 Markdown
marked渲染那三个 Markdown 字段;题干和选项正文按纯文本存储。 - 按内容哈希分类
为每一行计算
content_hash,并把每个 slug 归类为新增、变更、未变、恢复或退役。 - 在单个事务中写入
锁定
exam阻止新插入,结算已过期的考试,若仍有进行中的考试则中止并报告其 ID。按slugupsert 新增、变更和恢复的题目并置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。
- 在现有 Postgres 实例上创建专用的测验数据库。
- 启用
pgcrypto以支持gen_random_uuid()。 - 以非交互方式运行固定版本的 Better Auth CLI 迁移。
- 断言
"user".id的 PostgreSQL 类型是uuid。 - 运行
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.ts 和 clues.ts 保持纯函数且不访问数据库。queries.ts 是唯一编写 SQL 的模块;页面调用具名查询函数。src/lib/config.ts 只解析一次环境变量,因此页面代码从不直接读取 process.env,也不会自行编造兜底上限。
考试模式
创建考试
GET /exam/new 渲染考试类型、题目数量和时长。默认值为 QUIZ_DEFAULT_QUESTION_COUNT=65 和 QUIZ_DEFAULT_DURATION_MINUTES=90,但每场考试都可在 1–200 题、1–480 分钟范围内覆盖。
- 解决进行中的考试
在一个使用数据库时钟的事务中,先结算该用户已过期的考试。若仍有进行中的考试,则以 303 跳转到它第一道未作答的题。部分唯一索引会让并发创建收敛到同一场考试。
- 抽取题单
执行
SELECT id FROM question WHERE exam_type = $1 AND retired_at IS NULL ORDER BY random() LIMIT $2。若一道题也没有,则重新渲染表单且不插入任何数据。 - 写入截止时间
插入
exam,题目数取实际抽中的数量,deadline_at = transaction_timestamp() + duration_minutes。题库较小则生成一场较短但依然有效的考试,并在结果页说明。 - 固定顺序
在位置
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−1four_option_single3 个错误选项;始终保留 2 个可选2five_option_choose_two3 个错误选项;始终保留 2 个可选2
- 用
SELECT ... FOR UPDATE锁住当前作答记录。 - 若已揭示数量达到上限,则不做任何事并以 303 跳回;此时按钮本来就已隐藏。
- 否则随机挑一个尚未揭示的错误选项,追加到
revealed_option_ids,然后 303 跳回。
加锁让连续快速的线索请求串行化,因此它们不会重复揭示同一个选项、超出上限或互相覆盖更新。被揭示的选项会变灰并加删除线,显示其错误说明,且不可被选中。−1 这个上限保证线索永远不会直接把答案送到手上。
作答与重试
action=answer 锁住同一条记录,对 ID 归一化,拒绝不属于该题的选项 ID 以及任何已被排除的选项,然后存储选择、正确性和作答时间。重放该 POST 无法覆盖已作答的记录。
借助线索答对仍然算作答对;已揭示选项的数量保留了日后区分「独立答对」与「借助提示答对」的可能。「再试一次」会通过同样的防冲突路径创建一条全新的未作答记录,因此练习历史会不断累积。
计时器
exam.deadline_at 只写入一次,永不修改。每一次考试相关的 GET 和 POST 都会在考试行锁下比对数据库时钟。约十行的客户端脚本读取一个 data- 属性,每秒更新一次,归零时跳转到结果页。关闭标签页、手机休眠、暂停 JavaScript 或编辑页面都无法增加时间,因为服务端从不参考客户端的时钟。
错误处理
| 场景 | 行为 |
|---|---|
| 他人的或不存在的考试 ID | 404 |
| 位置超出范围 | 跳到第一道未作答的题;若已无剩余则结算并显示结果 |
| 考试已提交 | 跳转到结果页 |
| 截止时间之后的请求 | 自动提交并跳转到结果页 |
| 提交前请求结果 | 跳到第一道未作答的题 |
| 线索次数已达上限 | 忽略并跳回,不报错 |
| 在考试模式请求线索 | 路由不存在,按钮也从不渲染 |
| 未选择任何选项就提交 | 接受,判为错误 |
| 选项不属于该题 | 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_PORT | SMTP 服务端点 |
QUIZ_SMTP_USER / QUIZ_SMTP_PASSWORD | SMTP 凭据 |
QUIZ_EMAIL_FROM | 已验证的认证邮件发件人 |
配置在启动时校验一次,默认值也必须满足与提交值相同的边界。生产环境配置非法或不完整时服务会直接停止,而不是悄悄削弱认证或改变考试规则。
测试与验收标准
单元测试 · Vitest · 不依赖数据库
scoring.ts:单选的成功与失败;「选两个」的完全正确、部分正确和多选情形;空白作答。clues.ts:min(3, wrong − 1);四选项和五选项的代表性用例都得出 2;绝不挑中正确选项,也绝不重复已揭示的选项。- 截止时间比较:在
deadline_at之前、恰好等于、之后三种情况。 - 导入校验会拒绝重复的 slug、不符合
^[a-z0-9-]+$的 slug、缺失或拼错的表头列、空的My Answer、指向不存在选项或选中全部选项的My Answer、超过 100 的Community %,以及D为空却填了E的情形。 Why Wrong解析能把- **B:** …列表拆到正确的选项上并剥掉标签前缀;会拒绝缺失的标签、指向正确选项的多余标签、不匹配的行以及折行的续行。对真实 40 行中的全部 120 个条目往返解析,零异常。- 答案标签解析把
A、AB、ba和A B归一化为同一集合;拒绝AA和AF。 - 标签归一化把
S3; Transfer Acceleration;变成{s3, transfer-acceleration}。 - Markdown 范围:
explanation、study_note和why_wrong渲染成 HTML;题干和选项正文不经渲染直接透传,并在输出时转义。 content_hash对相同输入保持一致,重排标签或选项不会改变它;任何被导入字段变动都会改变它,而被忽略的列(ID、Q#、Flag)变动则永远不会。- 提交选项校验会拒绝外来的、重复的和已被揭示的 ID;判分使用归一化后的集合。
集成测试 · 真实 Postgres
- 导入两次而行数不变;就地更新一条解释;保持
question.id稳定且作答记录仍可解析。 - 文件未改动时的第二次导入把每一行都归类为未变、不产生任何写入,且所有
updated_at保持原样。 - 改动一个单元格后,恰好那一行被归类为变更,其余全部为未变。
content/中的两个文件导入为两种考试类型;新增第二个文件不会改动第一个文件的行,也不会让它们退役。--dry-run给出与真实运行相同的分类结果,且不提交任何内容。- 空操作导入在考试进行期间也能成功;带真实变更的导入会中止并指明进行中的考试。
- 删除一个选项时保留其余选项 ID;历史选择显示「选项已移除」占位内容。
- 删除、退役、排除、在历史结果中渲染,再以相同 ID 重新加回一道题。
- 创建考试恰好插入 N 条位于
1..N的 attempt 记录。 - 并发的练习页面加载只产生一条未作答记录;并发线索请求无法超出上限。
- 截止时间的 POST 与结果页 GET 竞争时,只产生一场已提交的考试且不会写入迟到的答案。
- 生成的 Better Auth schema 在应用迁移之前就把
"user".id暴露为 PostgreSQLuuid。
端到端测试 · Playwright
- 登录、开始考试、作答两题、刷新、恢复选择与正确的剩余时间、提交,并核对得分。
- 在截止时间之后打开考试会自动提交。
- 跨域的应用 POST 被拒绝且不产生任何变更。
- 用完所有练习线索,观察按钮消失,作答,然后看到所有错误选项的错误说明,包括从未被揭示的那些。
- 拒绝白名单外的注册;阻止白名单用户在验证前登录;完成一次密码重置。
v1 范围之外
不可变题目版本——规划中的第二阶段
v1 在任何考试进行期间都拒绝导入。这样做是安全的,但它假设存在一个维护窗口。一旦多个人全天都在考试,可能根本没有「没人在考试」的时刻,题库也就无法更新。解决办法是不再原地修改题目:每次导入都创建一个新的不可变版本,而每场考试都固定在它开始时所用的那个版本上。
- Alice 用题目 10 的版本 1 开始考试。
- 一次导入创建了题目 10 的版本 2。
- Alice 进行中的考试继续显示并按版本 1 判分。
- 导入之后开始的考试使用版本 2。
- 版本 1 仍可读取,用于 Alice 的成绩和历史复盘。
| 表 | 存放内容 |
|---|---|
question | 仅保留永久身份:id、slug、exam_type、retired_at |
question_revision | 每个版本一行:题干、解释、学习笔记、标签、出处列、content_hash、created_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,所有现有 attempt 的 question_revision_id 回填为该版本。不会丢失任何作答历史,因为选项 ID 是沿用而不是重新生成的。
其他推迟项
收藏
推迟未来加一张 favourite 表和一个筛选条件。
统计页面
只差界面作答数据现在就在采集;展示留到以后。
向量嵌入
砍掉没有语义搜索的使用场景。考试类型和标签筛选已经够用;等有了具体需求再看,backend/go 的 digitalworker 模块里已有 pgvector 先例。
后台编辑界面
不需要git 和 PR 仍然是内容编写流程。
题目配图
当前无需求目前的源材料都是纯文字。
考试标记待复查
省略编号题目网格已经能满足导航需求。
OAuth
省略已验证的邮箱加密码适合这个小范围的已知用户群。