软件工程课程作业:基于原生技术栈的简易在线考试系统全栈开发实践

作者:2452120、2452114

项目定位:基于JavaSpringBoot与原生交互设计的轻量化全栈教育平台

第一章:引言

在软件工程专业的学习过程中,我们接触并使用了大量的大型教务系统与在线考试平台。不可否认它们功能强大,但往往伴随着令人头疼的通病:前端组件库过度堆砌导致的极慢加载速度、极其复杂的中间件依赖以及高昂的学习与部署成本。

本项目——简易在线考试系统(OnlineExamSystem)的诞生,源于我们对“轻量化”与“高内聚”的追求。我们决定不依赖Vue/React等重量级前端框架,也不引入MyBatis-Plus的过度封装。2452120同学主导了底层数据库的精细化建模与SpringBoot后端核心业务流的构建;2452114同学则利用原生JavaScript与CSS状态机,徒手搭建了媲美单页面应用(SPA)的流畅交互体验,并攻克了ECharts图表在复杂DOM树中的渲染塌陷难题。

第二章:系统底层架构与数据库设计剖析

一个健壮的系统必须建立在严谨的数据库之上。2452120同学在系统初期,围绕“双角色(Teacher/Student)”与“核心业务流(题库-试卷-错题本)”构建了高内聚的E-R模型。

2.1双角色权限隔离(DualRoleSystem)

系统严格区分了教师与学生权限,这种隔离不仅体现在前端的路由分发上,更深植于后端的拦截器(Interceptor)中。

登录界面布局结构
标题行:醒目的“简易在线考试系统”系统标识。
输入行1:学号/教工号输入框,内置防抖动的正则校验提示。
输入行2:密码输入框(密文显示)。
操作行:包含“登录”主按钮与“点击注册”文字超链接。

image

image

2.2核心表结构设计(SchemaDesign)

为了实现“动态随机组卷”与“错题本动态瘦身”,我们抛弃了将整张试卷作为JSON大字段存储的取巧方案,而是采用了正规的关系型表结构。以下是核心数据表的建表SQL,展示了我们的外键约束与索引设计思考。

点击折叠/展开:系统核心数据库建表SQL(含优化索引)
-- 1. 用户表:存储双角色信息
CREATE TABLE sys_user (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名/学号',
    password VARCHAR(255) NOT NULL COMMENT '明文密码(简化版)',
    role ENUM('TEACHER', 'STUDENT') NOT NULL COMMENT '双角色隔离',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 2. 题库表:支持三种题型与难度梯度,为随机查询建立复合索引
CREATE TABLE questions (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    type VARCHAR(20) NOT NULL COMMENT '题型: SINGLE, MULTIPLE, JUDGE',
    content TEXT NOT NULL COMMENT '题干富文本',
    options JSON COMMENT '选项列表,利用MySQL8.0的JSON特性',
    correct_answer VARCHAR(255) NOT NULL,
    score INT NOT NULL DEFAULT 5,
    difficulty VARCHAR(20) DEFAULT 'MEDIUM' COMMENT '难度: EASY, MEDIUM, HARD',
    knowledge_point VARCHAR(100) COMMENT '知识点标签,用于ECharts图表统计',
    INDEX idx_type_diff (type, difficulty) -- 优化随机组卷的查询速度
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 3. 试卷主表
CREATE TABLE papers (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    title VARCHAR(100) NOT NULL,
    total_score INT NOT NULL,
    time_limit INT NOT NULL COMMENT '考试限时(分钟)',
    creator_id BIGINT NOT NULL COMMENT '出卷教师ID',
    create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 4. 试卷-题目中间表(多对多)
CREATE TABLE paper_questions (
    paper_id BIGINT NOT NULL,
    question_id BIGINT NOT NULL,
    question_order INT NOT NULL COMMENT '题目在试卷中的序号',
    PRIMARY KEY (paper_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 5. 动态错题本表
CREATE TABLE mistake_records (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    user_id BIGINT NOT NULL,
    question_id BIGINT NOT NULL,
    error_count INT DEFAULT 1 COMMENT '错题累计次数',
    last_error_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_user_question (user_id, question_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

设计思考:在questions表中,我们将选项设计为JSON类型,这极大方便了前端原生JS的解析,同时建立idx_type_diff复合索引,为后续学生端的“随机组卷”功能提供了底层查询速度保障。

第三章:教师端全景看板与业务闭环实现

宏观数据看板作为教师登录后的默认首页,我们将死板的数据转化为直观的图形。

主体图表行(ECharts渲染区)
左半区(饼图):展示“知识点分布占比”与“题型比例(单选/多选/判断)”。教师可借此判断题库建设是否偏科。
右半区(柱状图):展示“题目难度梯度分布(易/中/难)”,帮助教师把控整体考核难度。

image

题库管理矩阵是教师使用频率最高的模块,我们将其打造成了类似Excel的沉浸式数据表。

顶部操作工具条(Toolbar)
  右侧放置“+录入新题”主色调按钮与“批量删除”按钮。
数据矩阵表(核心骨架)
表头行(共7列):[序号] | [题型] | [知识点标签] | [难度级别] | [题干截断预览] | [分值] | [操作]。
行内操作按钮:每行的最后一列提供微型的“编辑”与“删除”图标按钮,支持单点精准操作。
丝滑交互逻辑:2452114同学利用CSS伪类:checked实现了点击行首复选框,整行背景立刻变为浅蓝色并带有位移动画的高亮效果。无需JS干预,性能极佳。

image
image
image

3.1前端攻坚:ECharts生命周期塌陷Bug的彻底解法

在2452114同学开发数据看板时,遇到了经典的前端渲染Bug。由于系统采用单页面(SPA)模拟架构,未激活的Tab面板处于display:none状态。当ECharts在隐藏元素中初始化时,由于获取不到DOM的实际宽高,会导致渲染出来的图表宽度仅有100px(俗称“幽灵图表”)。

解决原理:我们没有引入庞大的Vue生命周期钩子,而是利用JavaScript的事件循环(EventLoop)机制,配合宏任务(MacroTask)与ResizeObserver完美解决了这个问题。

点击折叠/展开:原生JS防塌陷图表渲染代码
const DashboardManager = {
    charts: {},
    
    initCharts: function() {
        const pieDom = document.getElementById('knowledgePieChart');
        const barDom = document.getElementById('difficultyBarChart');
        
        // 销毁旧实例防止内存泄漏
        if(this.charts.pie) this.charts.pie.dispose();
        if(this.charts.bar) this.charts.bar.dispose();

        this.charts.pie = echarts.init(pieDom);
        this.charts.bar = echarts.init(barDom);

        // 模拟从后端拉取的全量看板数据
        const pieOption = {
            title: { text: '知识点分布占比', left: 'center' },
            tooltip: { trigger: 'item' },
            series: [{
                type: 'pie',
                radius: '60%',
                data: [
                    {value: 35, name: '数据结构'},
                    {value: 20, name: '计算机网络'},
                    {value: 45, name: '操作系统'}
                ],
                emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } }
            }]
        };
        this.charts.pie.setOption(pieOption);
        // ...柱状图配置省略
    },

    // Tab切换拦截器
    switchTab: function(tabId) {
        document.querySelectorAll('.tab-content').forEach(el => el.style.display = 'none');
        document.getElementById(tabId).style.display = 'block';

        if (tabId === 'dashboardTab') {
            // 核心修复逻辑:宏任务延迟,等待浏览器CSSOM树计算完成
            setTimeout(() => {
                this.initCharts();
                // 强制重绘
                this.charts.pie.resize();
                this.charts.bar.resize();
            }, 50);
        }
    }
};

// 监听窗口大小变化实现真正的自适应
window.addEventListener('resize', () => {
    if(document.getElementById('dashboardTab').style.display === 'block'){
        DashboardManager.charts.pie?.resize();
        DashboardManager.charts.bar?.resize();
    }
});

3.2智能组卷与动态总分计算(后端逻辑)

教师在题库中勾选多道题目后,系统需要自动计算总分并生成试卷记录。2452120同学在后端利用Java8的StreamAPI实现了极简的聚合计算。
配置区:包含两个输入框——“试卷名称”与“考试限时(分钟)”。
底部右侧:固定在底部的“取消”与“确认并发布试卷”按钮。
image

image

点击折叠/展开:SpringBoot组卷业务实现代码
@RestController
@RequestMapping("/api/papers")
public class PaperController {

    @Autowired
    private PaperRepository paperRepository;
    @Autowired
    private QuestionRepository questionRepository;

    /**
     * 核心业务:接收教师勾选的题目ID,生成完整试卷并计算总分
     */
    @Transactional(rollbackFor = Exception.class)
    @PostMapping("/generate")
    public Result<Paper> generatePaper(@RequestBody PaperDto dto) {
        // 1. 根据传入的ID列表批量查询题目实体
        List<Question> selectedQuestions = questionRepository.findAllById(dto.getQuestionIds());
        
        if (selectedQuestions.isEmpty()) {
            return Result.error("题库中未找到对应的题目资源!");
        }

        // 2. 利用Stream流式编程,极速规约(Reduce)计算总分
        int autoCalculatedTotalScore = selectedQuestions.stream()
                .mapToInt(Question::getScore)
                .sum();

        // 3. 构建试卷元数据
        Paper newPaper = new Paper();
        newPaper.setTitle(dto.getTitle());
        newPaper.setTimeLimit(dto.getTimeLimit());
        newPaper.setCreatorId(dto.getTeacherId());
        newPaper.setTotalScore(autoCalculatedTotalScore);

        // 4. 保存主表并级联保存多对多中间表(JPA实现省略)
        Paper savedPaper = paperRepository.save(newPaper);
        
        return Result.success("试卷生成成功!总分:" + autoCalculatedTotalScore, savedPaper);
    }
}

3.3交互巧思:原生CSS状态机实现批量删除

在题目管理模块,2452114同学没有使用耗费性能的JS去逐个监听复选框状态,而是巧用CSS伪类:checked实现了类似Excel的批量高亮交互。结合后端的批量删除接口,做到了如丝般顺滑的体验。

image

image

点击折叠/展开:基于CSS状态机的高性能批量交互方案
/* CSS状态机核心思路:摒弃JS,纯靠CSS控制选中态UI */
/* 隐藏原生复选框,但保留其状态功能 */
.question-row input[type="checkbox"] {
    display: none;
}

/* 默认行样式 */
.question-row {
    background-color: #ffffff;
    transition: all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1);
    border-left: 4px solid transparent;
}

/* 状态机触发:当复选框被选中时,利用兄弟选择器(+)精准改变整行样式 */
.question-row input[type="checkbox"]:checked + .row-content {
    background-color: #f0f7ff; /* 浅蓝色选中背景 */
    border-left: 4px solid #1890ff; /* 左侧高亮指示条 */
    transform: translateX(5px); /* 微弱的位移动画提升打击感 */
    box-shadow: 0 4px 12px rgba(24,144,255,0.1);
}

第四章:学生端闭环学习引擎与核心算法

如果说教师端是“管理中心”,那么学生端就是真正的“核心算力区”。2452120同学为学生端设计了严密的考试逻辑闭环。
列表结构:展示已生成的套卷,包含列为[试卷编号] | [试卷名称] | [限时]| [总分] | [操作] 。
image

image

4.1自定义随机训练算法

为了满足学生自主刷题的需求,系统允许学生指定题目数量与限时。这就要求后端具备真正的“随机抽题”能力。
参数配置弹窗:弹窗包含——“抽取数量”、“考试用时”。
点击开始后,触发后端的ORDER BY RAND() LIMIT ?机制,毫秒级生成专属练习卷。

点击折叠/展开:MySQL随机抽题算法考量与代码
/**
 * Repository层核心接口
 * 思考:对于万级以下的中小型题库,ORDER BY RAND()是最优雅的原生方案。
 * 如果未来数据量达到百万级,我们会将这部分逻辑迁移至Redis并通过离散ID哈希算法实现。
 */
public interface QuestionRepository extends JpaRepository<Question, Long> {
    
    @Query(nativeQuery = true, 
           value = "SELECT * FROM questions WHERE type = :type ORDER BY RAND() LIMIT :limit")
    List<Question> findRandomQuestionsByType(
            @Param("type") String type, 
            @Param("limit") int limit
    );
}

image
沉浸式答题视图

顶部悬浮区(Fixed Header):巨大醒目的倒计时模块。
中央答题区:每道题作为一个独立卡片。
第一行:题目序号、题型标签、分值、粗体题干。
后续行:纵向排列的A、B、C、D选项(原生<label>包裹<input type="radio/checkbox">,实现点击整行均可选中,扩大点击热区)。

  • 底部操作区:悬浮的“交卷并查看成绩”按钮。

image

4.2极速判卷引擎与动态错题本系统

这是2452120同学设计的完美闭环体系。交卷瞬间完成以下视图的转换:

顶部出分通告:弹出横幅“XX总得分/满分:XX分”。
错题高亮机制:答错的选项底色被渲染为刺眼的红色,而正确的选项则高亮为绿色,并附带详细答案解析。
错题本自动化:所有判错的题目ID会被系统异步写入mistake_records数据表。
动态瘦身逻辑:在独立的“我的错题本”页面,学生重做错题一旦正确,系统会提供一个“移除错题”按钮,彻底清空该记录。

image

image

image

点击折叠/展开:判分引擎与错题本联动核心代码
    @Service
    public class ExamEngineService {

    @Autowired
    private QuestionRepository questionRepository;
    @Autowired
    private MistakeRecordRepository mistakeRepository;

    /**
     * 核心算法:实时判卷并更新错题本
     * @param userId 答题学生ID
     * @param userAnswers 学生提交的答案Map (Key: 题目ID, Value: 学生选择)
     */
    @Transactional
    public ExamResult evaluateAndRecord(Long userId, Map<Long, String> userAnswers) {
        int finalScore = 0;
        List<Long> currentMistakeIds = new ArrayList<>();

        // 1. 获取本次涉及的所有题目实体
        List<Question> questions = questionRepository.findAllById(userAnswers.keySet());

        for (Question q : questions) {
            String studentChoice = userAnswers.get(q.getId());
            // 2. 严格比对:考虑大小写与多选题的排序问题
            if (q.getCorrectAnswer().equalsIgnoreCase(studentChoice)) {
                finalScore += q.getScore();
            } else {
                // 3. 记录错题流水线
                currentMistakeIds.add(q.getId());
                recordMistake(userId, q.getId());
            }
        }

        return new ExamResult(finalScore, currentMistakeIds);
    }

    // 辅助方法:动态错题本的插入或更新(利用了MySQL的ON DUPLICATE KEY特性思维)
    private void recordMistake(Long userId, Long questionId) {
        MistakeRecord record = mistakeRepository.findByUserIdAndQuestionId(userId, questionId);
        if (record == null) {
            record = new MistakeRecord();
            record.setUserId(userId);
            record.setQuestionId(questionId);
            record.setErrorCount(1);
        } else {
            record.setErrorCount(record.getErrorCount() + 1);
        }
        mistakeRepository.save(record);
    }
}

第五章:工程化部署——脱机打包与局域网联机体验

作为一款实用型工具,我们考虑到了最严苛的校园网络环境(如无公网环境的实验室机房)。2452114同学利用Maven生命周期插件,将静态HTML/CSS/JS直接打包进SpringBoot的resources/static目录下。

通过Java获取本机的局域网IPv4地址,只需一台电脑运行java -jar exam-system.jar,全班同学连接同一路由器即可通过局域网IP直接访问该系统进行集中考试,彻底摆脱了复杂的Tomcat与Nginx配置。

点击折叠/展开:局域网联机部署与Maven构建配置
<!-- pom.xml中的核心构建逻辑 -->
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
            <!-- 将所有依赖以及前端静态资源打入一个Fat JAR包中 -->
            <executions>
                <execution>
                    <goals>
                        <goal>repackage</goal>
                    </goals>
                </execution>
            </executions>
        </plugin>
    </plugins>
</build>


// SpringBoot启动完成回调:自动打印局域网IP,方便学生扫码或输入网址接入
@Component
public class NetworkStartupLogger implements ApplicationListener<WebServerInitializedEvent> {
    @Override
    public void onApplicationEvent(WebServerInitializedEvent event) {
        try {
            String hostAddress = InetAddress.getLocalHost().getHostAddress();
            int port = event.getWebServer().getPort();
            System.out.println("==================================================");
            System.out.println("考试系统已启动,脱机局域网联机地址:");
            System.out.println("http://" + hostAddress + ":" + port);
            System.out.println("==================================================");
        } catch (UnknownHostException e) {
            e.printStackTrace();
        }
    }
}

第六章:总结与反思

从最初杂乱无章的对话.txt需求草图,到最终跑通整个考试流向的工程交付,这段全栈开发之旅让我们对软件工程有了全新的认知。

项目核心复盘

  1. 前后端解耦的红利:2452120同学专注于后端的RESTful接口防腐,2452114同学专注于前端渲染层,双方基于统一的JSON契约进行通信,极大缩短了调试时间。
  2. 拒绝过度设计的克制:我们没有引入Redis,而是榨干了MySQL与原生JS的性能,证明了在并发量不夸张的高校场景下,“原生轻量化”才是最优雅的解法。

未来迭代展望

在这个1.0版本跑通后,如果我们要将其商业化或推广至全校使用,接下来的架构演进方向将是:

  1. 缓存层引入:利用Redis缓存热门试卷结构,抵御开考瞬间的高并发查询。
  2. 实时反作弊通道:基于WebSocket建立全双工通信,教师端能够实时监控学生的切屏行为与答题进度。
  3. AI智能命题:接入大模型API,实现通过上传课件PDF一键抽取知识点并自动生成客观题矩阵。
posted @ 2026-04-19 20:34  Chloiris  阅读(10)  评论(0)    收藏  举报