您的当前位置:首页>全部文章>文章详情

【Webman+MySQL答题系统五】试卷管理模块:组卷与题目关联

果子发表于:2026-10-05 20:55:44浏览:1次TAG: #PHP #MySql #答题系统

本篇目标

实现组卷:创建试卷并关联题目(多对多)、自动汇总总分、试卷列表带题目数统计、删除时联动清理。

第五篇封面

一、试卷与题目的关系

一道题可进多份卷、一份卷含多道题,经典多对多,靠 paper_question 中间表维系:

试卷与题目关系图

二、模型

<?php
namespace app\model;

use support\Model;

class Paper extends Model
{
    protected $table = 'paper';
    protected $guarded = [];
}

三、控制器:创建试卷

app/controller/PaperController.php。创建时传题目 ID 列表,总分由服务端汇总——不要信任前端传来的 total_score:

<?php
namespace app\controller;

use support\Request;
use support\Db;
use app\model\Paper;

class PaperController
{
    public function add(Request $request)
    {
        $title      = trim((string)$request->post('title'));
        $passScore  = (float)$request->post('pass_score', 60);
        $duration   = (int)$request->post('duration', 60);
        $questionIds = $request->post('question_ids');   // 数组或逗号分隔字符串

        if ($title === '') {
            return error('试卷标题不能为空');
        }
        if (is_string($questionIds)) {
            $questionIds = array_filter(explode(',', $questionIds));
        }
        $questionIds = array_values(array_unique(array_map('intval', (array)$questionIds)));
        if (!$questionIds) {
            return error('至少选择一道题');
        }

        try {
            $paperId = Db::transaction(function () use ($title, $passScore, $duration, $questionIds) {
                // 取真实存在的题目与分值
                $rows = Db::table('question')
                    ->whereIn('id', $questionIds)
                    ->where('status', 1)
                    ->get(['id', 'score']);
                if ($rows->count() != count($questionIds)) {
                    throw new \Exception('存在无效或停用的题目');
                }
                $totalScore = $rows->sum('score');

                $paperId = Db::table('paper')->insertGetId([
                    'title'       => $title,
                    'total_score' => $totalScore,
                    'pass_score'  => $passScore,
                    'duration'    => $duration,
                    'status'      => 1,
                    'create_time' => date('Y-m-d H:i:s'),
                ]);

                // 保持传入顺序作为 sort
                $sort = 0;
                foreach ($questionIds as $qid) {
                    Db::table('paper_question')->insert([
                        'paper_id'    => $paperId,
                        'question_id' => $qid,
                        'sort'        => ++$sort,
                    ]);
                }
                return $paperId;
            });
        } catch (\Exception $e) {
            return error($e->getMessage());
        }
        return success(['id' => $paperId], '组卷成功');
    }
}

事务保证"试卷行 + N 行关联"要么全成功要么全回滚,中途失败不会留半张卷。分数汇总放在事务内查询,天然防并发下的读写不一致。

四、试卷列表(带题目数)

    public function list(Request $request)
    {
        $page  = max(1, (int)$request->get('page', 1));
        $limit = min(50, max(1, (int)$request->get('limit', 10)));

        $query = Db::table('paper as p')
            ->leftJoin('paper_question as pq', 'pq.paper_id', '=', 'p.id')
            ->groupBy('p.id', 'p.title', 'p.total_score', 'p.pass_score', 'p.duration', 'p.status')
            ->orderBy('p.id', 'desc');

        $total = Db::table('paper')->count();
        $items = $query->forPage($page, $limit)
            ->selectRaw('p.id, p.title, p.total_score, p.pass_score, p.duration, p.status, count(pq.id) as question_count')
            ->get();

        return success(['total' => $total, 'items' => $items]);
    }

两个易错点:

  • MySQL 5.7 默认 only_full_group_by,SELECT 里出现的非聚合列必须都进 GROUP BY,否则直接报 SQL 错;
  • Db::table 查询不走模型 casts,所以这里没有 JSON 字段问题,但记住这个差异,下一篇取卷时会专门处理。

五、停用与删除

    public function setStatus(Request $request)
    {
        $id     = (int)$request->post('id');
        $status = (int)$request->post('status') === 1 ? 1 : 0;
        Db::table('paper')->where('id', $id)->update(['status' => $status]);
        return success([], '操作成功');
    }

    public function delete(Request $request)
    {
        $id = (int)$request->post('id');
        $has = Db::table('record')->where('paper_id', $id)->count();
        if ($has) {
            return error('已有答题记录,建议停用而不是删除');
        }
        Db::transaction(function () use ($id) {
            Db::table('paper_question')->where('paper_id', $id)->delete();
            Db::table('paper')->where('id', $id)->delete();
        });
        return success([], '删除成功');
    }

有历史成绩的卷子停用即可,删了统计就断链了——数据一旦产生消费,物理删除就要慎重。

六、路由与验证

Route::group('/api/paper', function () {
    Route::post('/add', [PaperController::class, 'add']);
    Route::get('/list', [PaperController::class, 'list']);
    Route::post('/set_status', [PaperController::class, 'setStatus']);
    Route::post('/delete', [PaperController::class, 'delete']);
});

curl -X POST http://127.0.0.1:8787/api/paper/add \
  -d "title=Webman 基础卷" -d "pass_score=4" \
  -d "question_ids[]=1" -d "question_ids[]=2" -d "question_ids[]=3"
// {"code":0,"msg":"组卷成功","data":{"id":1}}

小结

组卷完成:事务写入、服务端算总分、列表聚合题目数、删除保护历史数据。下一篇切到学员视角:取卷作答。