综合练习:做题平台后端
这一课你将能做到
- 搭建「题目—提交—异步判分」闭环 API:出题、提交答案、查询结果
- 用 FormRequest 验证、Policy 授权、Event/Listener 解耦统计
- 用工厂+Feature 测试证明核心流程正确,并产出上线检查清单
为什么要学
单个知识点都不难,真实项目要求组合:一次「提交答案」同时涉及路由、认证、验证、Eloquent、事务、队列、事件、测试。项目选在线做题平台后端(本站题库极简版):教师出题,学生提交,系统异步判分。
先建立图像
users 1──N questions 1──N choices(is_correct 标记正误)
users 1──N submissions N──1 questions
POST /api/questions/{q}/submissions
→ 验证答案属于该题 → 写 pending → dispatch JudgeSubmission
worker → 判分 → graded → event SubmissionGraded → UpdateUserStats(ShouldQueue)
Laravel ↔ Spring:
| Laravel | Spring Boot |
|---|
| Controller + FormRequest | @RestController + @Valid |
| Job / Queue (Redis) | @Async + RabbitMQ |
| Event / Listener | ApplicationEvent |
| Policy | @PreAuthorize |
| Factory / RefreshDatabase | DataLoader + @Transactional |
跟着做
步骤 1:数据层
先 install:api,再 make:model Question -mf、Choice -m、Submission -mf。迁移字段:
$table->foreignId('created_by')->constrained('users');
$table->string('stem');
$table->string('type', 16);
$table->unsignedSmallInteger('score')->default(5);
$table->foreignId('question_id')->constrained()->cascadeOnDelete();
$table->string('content');
$table->boolean('is_correct')->default(false);
$table->foreignId('user_id')->constrained();
$table->foreignId('question_id')->constrained();
$table->json('answers');
$table->unsignedSmallInteger('score')->default(0);
$table->string('status', 16)->default('pending')->index();
$table->timestamp('graded_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'question_id']);
模型:
class Question extends Model
{
use HasFactory;
protected $fillable = ['stem', 'type', 'score', 'created_by'];
public function choices(): HasMany { return $this->hasMany(Choice::class); }
}
class Submission extends Model
{
use HasFactory;
protected $fillable = ['question_id', 'answers', 'score', 'status', 'graded_at'];
protected $casts = ['answers' => 'array', 'graded_at' => 'datetime'];
public function question(): BelongsTo { return $this->belongsTo(Question::class); }
}
User 补 submissions() 及两列 answer_total、answer_correct(默认 0)。
步骤 2:验证与权限
命令:make:request StoreQuestionRequest、make:policy QuestionPolicy。
// StoreQuestionRequest
public function authorize(): bool { return $this->user() !== null; }
public function rules(): array
{
return [
'stem' => ['required', 'string', 'max:500'],
'type' => ['required', Rule::in(['single', 'multiple'])],
'score' => ['required', 'integer', 'between:1,100'],
'choices' => ['required', 'array', 'min:2', 'max:8'],
'choices.*.content' => ['required', 'string', 'max:200'],
'choices.*.correct' => ['required', 'boolean'],
];
}
// QuestionPolicy:只有出题人能改
public function update(User $user, Question $question): bool
{
return $user->id === $question->created_by;
}
步骤 3:路由与控制器
Route::middleware('auth:sanctum')->group(function () {
Route::post('/questions', [QuestionController::class, 'store']);
Route::post('/questions/{question}/submissions', [SubmissionController::class, 'store']);
Route::get('/submissions/{submission}', [SubmissionController::class, 'show']);
});
public function store(StoreQuestionRequest $request): JsonResponse
{
$question = DB::transaction(function () use ($request) {
$q = Question::create($request->safe()->except('choices') + ['created_by' => $request->user()->id]);
$q->choices()->createMany(array_map(
fn (array $c) => ['content' => $c['content'], 'is_correct' => $c['correct']],
$request->validated('choices')
));
return $q;
});
return response()->json(['data' => $question->load('choices')], 201);
}
public function store(Request $request, Question $question): JsonResponse
{
$answers = $request->validate([
'answers' => ['required', 'array', 'min:1'],
'answers.*' => ['integer'],
])['answers'];
if (array_diff($answers, $question->choices()->pluck('id')->all()) !== []) {
abort(422, '存在不属于本题的选项');
}
$s = $request->user()->submissions()->create([
'question_id' => $question->id, 'answers' => $answers, 'status' => 'pending',
]);
JudgeSubmission::dispatch($s);
return response()->json(['data' => $s], 202);
}
步骤 4:判分 Job、事件与监听器
命令:make:job JudgeSubmission、make:event SubmissionGraded、make:listener UpdateUserStats --event=SubmissionGraded。
// JudgeSubmission::handle()
$q = $this->submission->question()->with('choices')->first();
$correct = $q->choices->where('is_correct', true)->pluck('id')->sort()->values()->all();
$given = collect($this->submission->answers)->sort()->values()->all();
$this->submission->update([
'status' => 'graded',
'score' => $correct === $given ? $q->score : 0,
'graded_at' => now(),
]);
SubmissionGraded::dispatch($this->submission, $correct === $given);
事件类携带 Submission $submission、bool $correct;监听器 UpdateUserStats implements ShouldQueue(异步、幂等),handle:
$event->submission->user->increment('answer_total');
$event->correct && $event->submission->user->increment('answer_correct');
步骤 5:测试闭环
class JudgeFlowTest extends TestCase
{
use RefreshDatabase;
public function test_correct_submission_gets_full_score(): void
{
$question = Question::factory()->has(Choice::factory()->count(3))->create();
$question->choices()->first()->update(['is_correct' => true]);
$correctId = $question->choices()->first()->id;
$this->actingAs(User::factory()->create())
->postJson("/api/questions/{$question->id}/submissions", ['answers' => [$correctId]])
->assertAccepted();
$this->assertDatabaseHas('submissions', [
'question_id' => $question->id, 'status' => 'graded', 'score' => $question->score,
]);
}
}
同理测伪造选项返回 422;仅验证投递用 Queue::fake()->assertPushed(...)。
步骤 6:调度兜底与上线
routes/console.php:Schedule::command('queue:retry')->everyFifteenMinutes();
上线:php artisan test 全绿;生产 APP_DEBUG=false、QUEUE_CONNECTION=redis;Supervisor 守护 Horizon 与调度;迁移后 queue:restart。
讲清楚要点
- 写入与判分分离:接口只快速落 pending,重活交 worker。
- 验证两道:FormRequest 管结构,业务层验证答案属于本题——后者防任意 id 探测。
- 判分是纯规则:集合比较可抽类做 Unit 测试;多选给分策略只是规则替换。
- 唯一约束兜底:
unique(user_id, question_id) 防并发重复;监听器失败不影响判分,重试即可但必须幂等。
你可能会懵的地方
- 测试里 Job 为何当场执行? phpunit 设了
QUEUE_CONNECTION=sync;验证投递用 Queue::fake()。
- Job 里为何重查 choices? Job 只存 submission 主键,消费重查保证按最新题目判分。
- 判分后怎么看到成绩?
GET /api/submissions/{id} 轮询;进阶用广播 WebSocket。
- 多选少选给半分怎么改? 只改 Job:交集/正确总数 × 分值,错选为 0。
错法对照
| 错法 | 问题 | 正解 |
|---|
| 请求里同步判分+统计 | 慢、外部依赖拖垮接口 | pending 落库+Job |
| 不校验 answers 归属 | 可提交任意 id | 业务验证+422 |
| is_correct 返回给学生端 | 答案泄露 | API 资源过滤字段 |
卡点急救
- 提交一直 pending:worker 是否存活、连接是否一致;
queue:failed 看堆栈。
- 答对却判 0:打印
$correct 与 $given 对比;检查工厂是否误置 is_correct。
- 工厂关系对不上:确认命名与关系名一致;SQLite 外键开启时注意创建顺序。
面试常问
-
描述一个异步业务闭环。
按「入口—状态机—消费者—副作用」讲:提交落 pending 投 Job;worker 集合判分更新 graded 发事件;监听器更新统计。唯一索引防重、监听器幂等、Horizon 监控。
-
如何防重复提交、伪造选项?
数据库 unique(user_id, question_id);业务校验 answers 是本题选项子集;Sanctum 绑定当前用户。
-
以后支持主观题、人工阅卷?
保持状态机(pending→auto_graded/manual_review→graded),Job 按题型分流,人工更新同一模型。
自测三问
- 从「提交」到看到得分,哪些类和表参与?每步失败是什么现象?
- 判分 Job 成功但统计监听器失败会怎样?如何兜底并避免重复计数?
- 新增「多选少选半分」和「截止后禁提交」分别改哪里?哪部分适合 Unit 测试?
小结一句话
核心不是代码量,而是让快速落库、异步处理、事件解耦、权限与测试各就各位,形成出错可自愈的闭环。
延伸
- 队列与事件(本 track 第 9 课)
- 测试(本 track 第 11 课)
- 后续方向:
- Laravel Echo + WebSocket 实时推送判分结果;
- 试卷 Exam + 批量提交,用
Bus::batch 追踪进度;
- 前后端分离,附件上传走 S3;
- GitHub Actions 跑测试与 Pint,Forge/Vapor 自动部署;
- Laravel Dusk 跑端到端 E2E 测试。