CodeRoadMap
首页路线题库资源库文章社区
CodeRoadMap

程序员的学习成长路线图。内容免费开放,登录后可同步路线图与课时进度。

由 程序员柏风 独立维护 · 关于作者

学习

  • 路线图
  • 课程
  • 面试题
  • 文章
  • 我

更多

  • 资源
  • 社区
  • 关于作者
  • 内容说明

© 2026 CodeRoadMap

津ICP备2026012044号-1·津公网安备12011002026155号·coderoadmap@126.com
Laravel 实战入门/课时/综合练习
RawBlame
课时目录12 节

Laravel 实战入门

  1. 01Laravel 概览
  2. 02目录结构
  3. 03路由
  4. 04控制器
  5. 05Blade 模板
  6. 06Eloquent ORM
  7. 07认证与授权
  8. 08验证与异常
  9. 09队列与事件
  10. 10运维与工具
  11. 11测试
  12. 12综合练习

综合练习

Blog API + Blade 后台。

30 分钟

综合练习:做题平台后端

这一课你将能做到

  1. 搭建「题目—提交—异步判分」闭环 API:出题、提交答案、查询结果
  2. 用 FormRequest 验证、Policy 授权、Event/Listener 解耦统计
  3. 用工厂+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:

LaravelSpring Boot
Controller + FormRequest@RestController + @Valid
Job / Queue (Redis)@Async + RabbitMQ
Event / ListenerApplicationEvent
Policy@PreAuthorize
Factory / RefreshDatabaseDataLoader + @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:路由与控制器

routes/api.php:

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。

讲清楚要点

  1. 写入与判分分离:接口只快速落 pending,重活交 worker。
  2. 验证两道:FormRequest 管结构,业务层验证答案属于本题——后者防任意 id 探测。
  3. 判分是纯规则:集合比较可抽类做 Unit 测试;多选给分策略只是规则替换。
  4. 唯一约束兜底: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 资源过滤字段

卡点急救

  1. 提交一直 pending:worker 是否存活、连接是否一致;queue:failed 看堆栈。
  2. 答对却判 0:打印 $correct 与 $given 对比;检查工厂是否误置 is_correct。
  3. 工厂关系对不上:确认命名与关系名一致;SQLite 外键开启时注意创建顺序。

面试常问

  • 描述一个异步业务闭环。 按「入口—状态机—消费者—副作用」讲:提交落 pending 投 Job;worker 集合判分更新 graded 发事件;监听器更新统计。唯一索引防重、监听器幂等、Horizon 监控。

  • 如何防重复提交、伪造选项? 数据库 unique(user_id, question_id);业务校验 answers 是本题选项子集;Sanctum 绑定当前用户。

  • 以后支持主观题、人工阅卷? 保持状态机(pending→auto_graded/manual_review→graded),Job 按题型分流,人工更新同一模型。

自测三问

  1. 从「提交」到看到得分,哪些类和表参与?每步失败是什么现象?
  2. 判分 Job 成功但统计监听器失败会怎样?如何兜底并避免重复计数?
  3. 新增「多选少选半分」和「截止后禁提交」分别改哪里?哪部分适合 Unit 测试?

小结一句话

核心不是代码量,而是让快速落库、异步处理、事件解耦、权限与测试各就各位,形成出错可自愈的闭环。

延伸

  • 队列与事件(本 track 第 9 课)
  • 测试(本 track 第 11 课)
  • 后续方向:
    1. Laravel Echo + WebSocket 实时推送判分结果;
    2. 试卷 Exam + 批量提交,用 Bus::batch 追踪进度;
    3. 前后端分离,附件上传走 S3;
    4. GitHub Actions 跑测试与 Pint,Forge/Vapor 自动部署;
    5. Laravel Dusk 跑端到端 E2E 测试。
加载本节练习…
← 上一课测试