AI项目博客
记录 AI 工程实践与大模型应用

从零搭建 AI 在线考试系统(一):工程骨架与数据模型

这个系列会把「AI 在线考试系统」从零搭一遍:建表、配环境、写关键代码、跑起来验证。每一篇结束你都能有一个能运行的东西,而不是读完一堆设计原则。

这是第一篇,目标是把工程骨架和数据库搭起来,让服务能启动。

AI 在线考试系统管理端首页

图:管理端首页,题库、试卷、考试、成绩四个入口。

一、前置环境

组件 版本 说明
JDK 17+ Spring Boot 3.x 的硬性要求
Maven 3.8+ —
MySQL 8.0.33 字符集 utf8mb4
Redis 任意版本 用于答题草稿暂存和分布式锁,不需要 Redis 8 的 search 模块

先建库(本系统与认证中心共用一个库,因为要跨库直读 sys_* 元数据表):

CREATE DATABASE IF NOT EXISTS `somp`

  DEFAULT CHARACTER SET utf8mb4

  COLLATE utf8mb4_general_ci;

二、工程骨架

整个平台是 Maven 多模块,考试子系统涉及三个后端模块和两个前端工程:

ai-parent/

├── ai-modules/

│   ├── exam-service/          # 业务服务层(Service + Mapper + 实体)

│   └── exam-controller/       # REST 接口层

├── ai-apps/

│   └── exam-app/              # 启动模块(:18004)

└── ai-front-app/

    ├── exam-admin-ui/         # 管理端(:3002)

    └── exam-student-ui/       # 考生端(:3003)

这个拆分不是形式主义:exam-service 里的业务方法可以被同进程的其它模块直接调用(比如后面 Agent 的工具实现就复用检索 Service),而不必绕过 HTTP 层。

2.1 父 pom 关键版本

根 pom.xml 里这几个版本必须锁死,都是踩过坑的:

<properties>

    <java.version>17</java.version>

    <spring-boot.version>3.5.4</spring-boot.version>

    <!-- 3.5.16:保留旧包名的最后一个版本。3.5.17 把 IService/ServiceImpl

         从 extension.service 迁到 spring.service,本仓有 70+ 处 import 受影响 -->

    <mybatis-plus.version>3.5.16</mybatis-plus.version>

    <sa-token.version>1.46.0</sa-token.version>

    <shedlock.version>6.10.0</shedlock.version>

</properties>

2.2 exam-app 启动模块的依赖

<dependencies>

    <!-- 业务层 -->

    <dependency>

        <groupId>com.laijun</groupId>

        <artifactId>exam-controller</artifactId>

        <version>${project.version}</version>

    </dependency>

    <!-- MyBatis-Plus JSQLParser 配套模块(3.5.9+ 拆包,分页/数据权限拦截器所在模块) -->

    <dependency>

        <groupId>com.baomidou</groupId>

        <artifactId>mybatis-plus-jsqlparser-4.9</artifactId>

    </dependency>

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-web</artifactId>

    </dependency>

    <dependency>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-data-redis</artifactId>

    </dependency>

    <!-- Sa-Token:会话 + SSO + 注解鉴权 -->

    <dependency>

        <groupId>cn.dev33</groupId>

        <artifactId>sa-token-spring-boot3-starter</artifactId>

    </dependency>

    <dependency>

        <groupId>cn.dev33</groupId>

        <artifactId>sa-token-sso</artifactId>

    </dependency>

    <dependency>

        <groupId>cn.dev33</groupId>

        <artifactId>sa-token-redis-template</artifactId>

    </dependency>

    <!-- ShedLock:定时任务分布式互斥(超时答卷强制交卷) -->

    <dependency>

        <groupId>net.javacrumbs.shedlock</groupId>

        <artifactId>shedlock-spring</artifactId>

    </dependency>

    <dependency>

        <groupId>net.javacrumbs.shedlock</groupId>

        <artifactId>shedlock-provider-redis-spring</artifactId>

    </dependency>

    <!-- 平台公共组件 -->

    <dependency>

        <groupId>com.laijun</groupId>

        <artifactId>commons-base</artifactId>

        <version>${project.version}</version>

    </dependency>

    <dependency>

        <groupId>com.laijun</groupId>

        <artifactId>sso-client</artifactId>

        <version>${project.version}</version>

    </dependency>

</dependencies>

注意 mybatis-plus-jsqlparser-4.9 这一条:MyBatis-Plus 3.5.9 起把三个拦截器拆到了这个独立模块(PaginationInnerInterceptor / DataPermissionInterceptor / TenantLineInnerInterceptor)。漏加的后果是编译失败,或者更隐蔽的分页静默失效——查询不再分页,但没有任何报错。

2.3 启动类

package com.laijun;

import org.springframework.boot.SpringApplication;

import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication

public class ExamPlatformApplication {

    public static void main(String[] args) {

        SpringApplication.run(ExamPlatformApplication.class, args);

    }

}

包根放 com.laijun,靠隐式扫描覆盖 com.laijun.exam.* 和 com.laijun.common.*,不用显式写 scanBasePackages。

2.4 拦截器注册顺序

@Configuration

public class MybatisPlusConfig {

    @Bean

    public MybatisPlusInterceptor mybatisPlusInterceptor() {

        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();

        // 顺序不能改:数据权限 → 租户 → 分页

        // 分页必须最后:它要在前面两个拦截器改写完 SQL 之后再包 count 语句

        interceptor.addInnerInterceptor(new DataPermissionInterceptor());

        interceptor.addInnerInterceptor(new TenantLineInnerInterceptor());

        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));

        return interceptor;

    }

}

这个顺序是有契约的(commons-tenant 的单测里有断言)。如果分页拦截器排在租户之前,count 语句会在租户条件注入之前生成,导致总数和实际数据对不上。

三、建表

完整 DDL 一共 10 张表,分五组。下面是可以直接执行的脚本。

3.1 题库

-- ---------- 知识点 / 分类(树形) ----------

CREATE TABLE IF NOT EXISTS `exam_category` (

    `id`            BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`     BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `parent_id`     BIGINT       DEFAULT NULL COMMENT '父分类Id(NULL 表示顶级)',

    `category_name` VARCHAR(50)  NOT NULL COMMENT '分类/知识点名称',

    `sort_no`       INT          DEFAULT 0 COMMENT '排序号(小值靠前)',

    `status`        TINYINT      DEFAULT 1 COMMENT '状态(1启用 0禁用)',

    `remark`        VARCHAR(200) DEFAULT NULL COMMENT '备注',

    `created_id`    BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time`  DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`    BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time`  DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    KEY `idx_parent_id` (`parent_id`),

    KEY `idx_category_name` (`category_name`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-题目分类/知识点';

-- ---------- 题库 ----------

CREATE TABLE IF NOT EXISTS `exam_question` (

    `id`            BIGINT        NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`     BIGINT        DEFAULT NULL COMMENT '所属租户Id',

    `category_id`   BIGINT        DEFAULT NULL COMMENT '所属分类/知识点Id',

    `question_type` TINYINT       NOT NULL DEFAULT 1 COMMENT '题型(1单选 2多选 3判断 4填空 5简答)',

    `stem`          TEXT          NOT NULL COMMENT '题干(支持富文本 HTML)',

    `stem_hash`     VARCHAR(64)   DEFAULT NULL COMMENT '题干归一化哈希(去标签/去空白/小写后 MD5),用于查重',

    `options`       TEXT          COMMENT '选项JSON数组[{"key":"A","content":"..."}]',

    `answer`        VARCHAR(2000) DEFAULT NULL COMMENT '标准答案(归一化后,见下方说明)',

    `analysis`      TEXT          COMMENT '答案解析',

    `difficulty`    TINYINT       DEFAULT 3 COMMENT '难度(1-5,越大越难)',

    `default_score` DECIMAL(5,1)  DEFAULT 2.0 COMMENT '默认分值',

    `status`        TINYINT       DEFAULT 1 COMMENT '状态(1启用 0禁用)',

    `source`        TINYINT       NOT NULL DEFAULT 1 COMMENT '来源(1人工录入 2AI生成 3Excel导入)',

    `created_id`    BIGINT        DEFAULT NULL COMMENT '创建人Id',

    `created_time`  DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`    BIGINT        DEFAULT NULL COMMENT '修改人Id',

    `updated_time`  DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    KEY `idx_category_id` (`category_id`),

    KEY `idx_type_difficulty` (`question_type`, `difficulty`),

    KEY `idx_status` (`status`),

    KEY `idx_stem_hash` (`stem_hash`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-题库';

answer 字段的归一化口径是这套设计的地基,必须在写入时统一,否则判分没法做字符串比较:

题型 规则 示例
单选 单个选项 key,大写 A
多选 去重后按字母升序,逗号分隔 A,C(不是 C,A)
判断 1 对 / 0 错 1
填空 各空答案用 | 分隔 北京|上海
简答 纯文本参考答案,可空 —

3.2 试卷

-- ---------- 试卷 ----------

CREATE TABLE IF NOT EXISTS `exam_paper` (

    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`      BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `paper_name`     VARCHAR(100) NOT NULL COMMENT '试卷名称',

    `gen_mode`       TINYINT      NOT NULL DEFAULT 1 COMMENT '组卷方式(1固定组卷 2规则抽题)',

    `question_count` INT          DEFAULT 0 COMMENT '题目数量',

    `total_score`    DECIMAL(6,1) DEFAULT 0 COMMENT '试卷总分',

    `pass_score`     DECIMAL(6,1) DEFAULT 60 COMMENT '及格分',

    `duration_min`   INT          DEFAULT 60 COMMENT '建议考试时长(分钟)',

    `status`         TINYINT      DEFAULT 1 COMMENT '状态(1启用 0禁用)',

    `remark`         VARCHAR(200) DEFAULT NULL COMMENT '备注',

    `created_id`     BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`     BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    KEY `idx_paper_name` (`paper_name`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-试卷';

-- ---------- 规则组卷条款(gen_mode=2 时生效) ----------

CREATE TABLE IF NOT EXISTS `exam_paper_rule` (

    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`      BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `paper_id`       BIGINT       NOT NULL COMMENT '所属试卷Id',

    `category_id`    BIGINT       DEFAULT NULL COMMENT '抽题知识点Id(NULL 不限)',

    `question_type`  TINYINT      DEFAULT NULL COMMENT '抽题题型(NULL 不限)',

    `difficulty`     TINYINT      DEFAULT NULL COMMENT '抽题难度(NULL 不限)',

    `question_count` INT          NOT NULL DEFAULT 0 COMMENT '抽取题目数量',

    `score_per`      DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '每题分值',

    `sort_no`        INT          DEFAULT 0 COMMENT '排序号',

    `created_id`     BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`     BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    KEY `idx_paper_id` (`paper_id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-规则组卷条款';

-- ---------- 试卷题目明细(固定组卷录入 / 规则抽题结果) ----------

CREATE TABLE IF NOT EXISTS `exam_paper_question` (

    `id`          BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`   BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `paper_id`    BIGINT       NOT NULL COMMENT '所属试卷Id',

    `question_id` BIGINT       NOT NULL COMMENT '题目Id',

    `sort_no`     INT          DEFAULT 0 COMMENT '题号(从1开始)',

    `score`       DECIMAL(5,1) NOT NULL DEFAULT 0 COMMENT '本题分值',

    `created_id`  BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time` DATETIME    DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`  BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time` DATETIME    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    UNIQUE KEY `uk_paper_question` (`paper_id`, `question_id`),

    KEY `idx_question_id` (`question_id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-试卷题目明细';

3.3 考试场次与参考名单

CREATE TABLE IF NOT EXISTS `exam_exam` (

    `id`                      BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`               BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `paper_id`                BIGINT       NOT NULL COMMENT '试卷Id',

    `exam_name`               VARCHAR(100) NOT NULL COMMENT '考试名称',

    `start_time`              DATETIME     DEFAULT NULL COMMENT '考试开始时间(时间窗)',

    `end_time`                DATETIME     DEFAULT NULL COMMENT '考试结束时间(到点禁止入场)',

    `duration_min`            INT          DEFAULT 60 COMMENT '考试时长(分钟),从考生进入开始计时',

    `attempt_limit`           INT          DEFAULT 1 COMMENT '允许考试次数',

    `shuffle_question`        TINYINT      DEFAULT 0 COMMENT '题目乱序(1是 0否)',

    `shuffle_option`          TINYINT      DEFAULT 0 COMMENT '选项乱序(1是 0否)',

    `multiple_partial_score`  TINYINT      DEFAULT 0 COMMENT '多选半对给分(1是 0否)',

    `blank_partial_score`     TINYINT      DEFAULT 0 COMMENT '填空按空给分(1是 0否)',

    `anti_cheat_enabled`      TINYINT      DEFAULT 0 COMMENT '防作弊开关',

    `switch_limit`            INT          DEFAULT 0 COMMENT '允许切屏次数(0=仅记录不强制)',

    `pass_score`              DECIMAL(6,1) DEFAULT NULL COMMENT '及格分(为空取试卷及格分)',

    `status`                  TINYINT      DEFAULT 1 COMMENT '状态(1草稿 2已发布 3已结束)',

    `remark`                  VARCHAR(200) DEFAULT NULL COMMENT '备注',

    `created_id`              BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time`            DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`              BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time`            DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    KEY `idx_paper_id` (`paper_id`),

    KEY `idx_status` (`status`),

    KEY `idx_time_window` (`start_time`, `end_time`),

    KEY `idx_exam_name` (`exam_name`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-考试场次';

CREATE TABLE IF NOT EXISTS `exam_candidate` (

    `id`           BIGINT      NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`    BIGINT      DEFAULT NULL COMMENT '所属租户Id',

    `exam_id`      BIGINT      NOT NULL COMMENT '考试场次Id',

    `user_id`      BIGINT      NOT NULL COMMENT '考生用户Id',

    `user_name`    VARCHAR(50) DEFAULT NULL COMMENT '考生姓名(冗余快照,避免列表 JOIN)',

    `status`       TINYINT     DEFAULT 1 COMMENT '状态(1待考 2考试中 3已交卷)',

    `created_id`   BIGINT      DEFAULT NULL COMMENT '创建人Id',

    `created_time` DATETIME    DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`   BIGINT      DEFAULT NULL COMMENT '修改人Id',

    `updated_time` DATETIME    DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    UNIQUE KEY `uk_exam_user` (`exam_id`, `user_id`),

    KEY `idx_user_id` (`user_id`),

    KEY `idx_user_name` (`user_name`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-参考名单';

3.4 答卷与答题明细

CREATE TABLE IF NOT EXISTS `exam_record` (

    `id`             BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`      BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `exam_id`        BIGINT       NOT NULL COMMENT '考试场次Id',

    `paper_id`       BIGINT       NOT NULL COMMENT '试卷Id',

    `user_id`        BIGINT       NOT NULL COMMENT '考生用户Id',

    `user_name`      VARCHAR(50)  DEFAULT NULL COMMENT '考生姓名(冗余快照)',

    `attempt_no`     INT          NOT NULL DEFAULT 1 COMMENT '第几次作答',

    `paper_snapshot` LONGTEXT     COMMENT '组卷快照JSON:定格的题目顺序/分值/选项顺序',

    `start_time`     DATETIME     DEFAULT NULL COMMENT '开始作答时间',

    `deadline_time`  DATETIME     DEFAULT NULL COMMENT '个人截止时间(服务端权威)',

    `submit_time`    DATETIME     DEFAULT NULL COMMENT '交卷时间',

    `total_score`    DECIMAL(6,1) DEFAULT 0 COMMENT '得分',

    `pass_flag`      TINYINT      DEFAULT NULL COMMENT '是否及格(1是 0否)',

    `status`         TINYINT      DEFAULT 1 COMMENT '状态(1进行中 2已交卷 3超时交卷)',

    `switch_count`   INT          DEFAULT 0 COMMENT '切屏次数(服务端累加)',

    `manual_pending` TINYINT      DEFAULT 0 COMMENT '是否存在待人工阅卷的题',

    `created_id`     BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`     BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time`   DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    UNIQUE KEY `uk_exam_user_attempt` (`exam_id`, `user_id`, `attempt_no`),

    KEY `idx_user_id` (`user_id`),

    KEY `idx_status` (`status`),

    KEY `idx_user_name` (`user_name`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-答卷';

CREATE TABLE IF NOT EXISTS `exam_answer` (

    `id`            BIGINT        NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`     BIGINT        DEFAULT NULL COMMENT '所属租户Id',

    `record_id`     BIGINT        NOT NULL COMMENT '答卷Id',

    `question_id`   BIGINT        NOT NULL COMMENT '题目Id',

    `question_type` TINYINT       NOT NULL COMMENT '题型快照',

    `user_answer`   VARCHAR(4000) DEFAULT NULL COMMENT '考生作答(口径同题库 answer)',

    `is_correct`    TINYINT       DEFAULT 0 COMMENT '是否正确(简答题按是否得满分折算)',

    `score`         DECIMAL(5,1)  DEFAULT 0 COMMENT '本题得分',

    `graded`        TINYINT       DEFAULT 0 COMMENT '是否已判分(客观题交卷即1,简答阅卷后置1)',

    `created_id`    BIGINT        DEFAULT NULL COMMENT '创建人Id',

    `created_time`  DATETIME      DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`    BIGINT        DEFAULT NULL COMMENT '修改人Id',

    `updated_time`  DATETIME      DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_id` (`tenant_id`),

    UNIQUE KEY `uk_record_question` (`record_id`, `question_id`),

    KEY `idx_question_id` (`question_id`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-答题明细';

这里有三个设计点必须解释清楚,否则后续代码会写错:

① paper_snapshot 为什么必须存在

进入考试时,把题目顺序、每题分值、选项乱序后的顺序序列化成 JSON 存进这一列。之后判分、回顾、统计全部读快照,不回查试卷表。

原因是题库和试卷会被修改——题干可以改、分值可以调。如果不定格,一个月后看历史成绩会出争议:判分用的是新题目,考生答的是旧题目。快照让历史成绩成为不可变的事实。

② attempt_no 支持多次作答

唯一键是 (exam_id, user_id, attempt_no),而不是 (exam_id, user_id)。这样同一场考试同一考生可以有多份答卷(补考场景),成绩取最好一次,错题本跨全部答卷归集。

③ 索引不是随便加的

看 exam_record:idx_exam_id 被删掉了,因为它被 uk_exam_user_attempt(exam_id, user_id, attempt_no) 的最左前缀覆盖。但 idx_user_id 必须保留——user_id 不是那个唯一键的最左前缀,”我的考试”列表查询会失去索引。

3.5 AI 调用流水

CREATE TABLE IF NOT EXISTS `exam_ai_invoke_log` (

    `id`           BIGINT       NOT NULL AUTO_INCREMENT COMMENT 'Id',

    `tenant_id`    BIGINT       DEFAULT NULL COMMENT '所属租户Id',

    `scene`        TINYINT      NOT NULL COMMENT '场景(1出题 2试评 3解析生成 4错题讲解 5语义查重 6智能组卷)',

    `user_id`      BIGINT       DEFAULT NULL COMMENT '调用人Id',

    `user_name`    VARCHAR(64)  DEFAULT NULL COMMENT '调用人姓名(冗余留痕)',

    `model`        VARCHAR(64)  DEFAULT NULL COMMENT '模型名',

    `target_id`    BIGINT       DEFAULT NULL COMMENT '业务对象Id(出题=知识点Id,试评=答题明细Id)',

    `total_tokens` INT          DEFAULT NULL COMMENT 'token 用量',

    `success_flag` TINYINT      NOT NULL DEFAULT 1 COMMENT '是否成功(1成功 0失败)',

    `error_msg`    VARCHAR(500) DEFAULT NULL COMMENT '失败原因',

    `elapsed_ms`   BIGINT       DEFAULT NULL COMMENT '耗时(毫秒)',

    `created_id`   BIGINT       DEFAULT NULL COMMENT '创建人Id',

    `created_time` DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',

    `updated_id`   BIGINT       DEFAULT NULL COMMENT '修改人Id',

    `updated_time` DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改时间',

    PRIMARY KEY (`id`),

    KEY `idx_tenant_user_time` (`tenant_id`, `user_id`, `created_time`),

    KEY `idx_scene_time` (`scene`, `created_time`)

) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试-AI调用流水';

两个索引对应两个真实查询:idx_tenant_user_time 服务于”这个租户这个人今天调了多少次”(配额判定),idx_scene_time 服务于”哪个功能在花钱”。

失败调用也要记(success_flag=0):超时和报错在多数供应商那里一样计费。

四、配置文件

exam-app/src/main/resources/application.yml:

server:

  port: 18004

  servlet:

    encoding:

      charset: UTF-8

spring:

  application:

    name: exam-app

  servlet:

    multipart:

      # 题库 Excel 批量导入

      max-file-size: 50MB

      max-request-size: 50MB

  profiles:

    # 部署时务必用环境变量显式指定,避免"漏传参数就连到开发环境"

    active: dev

  # AI 对话模型(OpenAI 兼容协议):置 none 则 AI 入口显示「未启用」

  ai:

    model:

      chat: openai

    openai:

      base-url: https://api.your-provider.com/v1

      api-key: ${AI_API_KEY}          # 走环境变量,不要写在文件里

      chat:

        options:

          model: your-chat-model

          temperature: 0.7

mybatis-plus:

  configuration:

    log-impl: org.apache.ibatis.logging.nologging.NoLoggingImpl

  global-config:

    db-config:

      id-type: auto

  mapper-locations: classpath*:mapper/**/*.xml

# Sa-Token 本地会话(SSO 登录成功后,本子系统独立维护一份会话)

sa-token:

  token-name: satoken

  timeout: 3000

  is-read-cookie: false

  is-read-header: true

  sso-client:

    reg-logout-call: true      # 认证中心退出 → 子系统全端下线

    is-slo: true

    is-http: true

# 考试侧 AI 业务上限("允许多少")

# 连接配置走 spring.ai.*("连哪里")—— 两者分开,避免为调额度却动了模型地址

exam:

  ai:

    limit:

      # 每人每日 AI 调用上限,0=不限

      # AI 调用真实计费而"点击"免费,没有这道闸,一个人连点 20 次就是 20 次计费

      daily-limit-per-user: 0

      max-count: 20            # 单次出题题量上限

      batch-size: 10           # 拆批调用,避免单次响应过长被判超时

platform:

  data-permission:

    enabled: true

    ignore-tables:

      # 认证中心元数据表(共库直读,大量 LEFT JOIN 取姓名)

      - sys_user

      - sys_role

      - sys_org

      - sys_tenant

      # 考试域:可见范围由「租户 + 参考名单」显式决定,不走组织过滤

      - exam_category

      - exam_question

      - exam_paper

      - exam_paper_rule

      - exam_paper_question

      - exam_exam

      - exam_candidate

      - exam_record

      - exam_answer

      - exam_ai_invoke_log

  tenant:

    data-isolation:

      enabled: true

      # 必须登记,否则租户拦截器不管这张表

      tenant-tables:

        - exam_category

        - exam_question

        - exam_paper

        - exam_paper_rule

        - exam_paper_question

        - exam_exam

        - exam_candidate

        - exam_record

        - exam_answer

        - exam_ai_invoke_log

三个配置要点:

① API Key 用环境变量,不要硬编码。上面写的是 ${AI_API_KEY},启动前 export AI_API_KEY=xxx。

② 两张白名单必须登记,漏了会静默出问题:

  • tenant-tables 漏登记 → 租户拦截器不管这张表,多租户查询会跨租户返回数据
  • ignore-tables 漏登记 → 数据权限拦截器会按 created_id 关联组织表注入 EXISTS 子查询,考生没配组织归属就查不到自己的答卷

这两个问题在单租户开发环境完全发现不了,上线才爆。

③ id-type: auto 与 DDL 里的 AUTO_INCREMENT 对应,不要用雪花 ID。

搭建流程

五、启动验证

# 1. 建表

mysql -h127.0.0.1 -uroot -p somp < sql/ddl/iam-ddl.sql

mysql -h127.0.0.1 -uroot -p somp < sql/ddl/exam-ddl.sql

# 2. 初始化菜单/权限/应用注册

mysql -h127.0.0.1 -uroot -p somp < sql/init/iam-menu-init.sql

mysql -h127.0.0.1 -uroot -p somp < sql/init/user-init.sql

mysql -h127.0.0.1 -uroot -p somp < sql/init/exam-app-init.sql

# 3. 取出 SSO 密钥(未注入会启动失败,这是刻意设计:共享密钥不该进仓库)

mysql -h127.0.0.1 -uroot -p somp 

  -e "SELECT app_secret FROM sys_application WHERE app_code='exam-app';"

# 4. 注入密钥并启动

export DEV_EXAM_SSO_SECRET_KEY="<上一步查到的值>"

mvn -pl ai-apps/exam-app -am spring-boot:run

启动成功的标志是日志里出现:

Tomcat started on port 18004

Started ExamPlatformApplication in x.xxx seconds

验证表都建好了:

SELECT TABLE_NAME, TABLE_COMMENT

FROM information_schema.TABLES

WHERE TABLE_SCHEMA = 'somp' AND TABLE_NAME LIKE 'exam_%'

ORDER BY TABLE_NAME;

应该返回 10 张表。

六、小结

这一篇做完,你应该有了:能启动的服务、10 张建好的表、正确的租户/数据权限白名单配置。

三个最容易出问题的地方再强调一次:

  1. MyBatis-Plus 要加 mybatis-plus-jsqlparser-4.9——漏加会导致分页静默失效
  2. 拦截器注册顺序不能改——数据权限 → 租户 → 分页
  3. 两张白名单都要登记——漏了在单租户环境测不出来

下一篇讲组卷抽题:完整实现规则抽题算法、处理”预览说够抽取却不够”,以及按组织批量指派考生。

赞(1)
未经允许不得转载:AI项目博客 » 从零搭建 AI 在线考试系统(一):工程骨架与数据模型

评论 1

  1. #1

    干货满满!

    游客 江苏省 南京市 · IP 49.91.145.*5天前回复

AI项目博客

专注 AI 工程化落地 —— 覆盖智能体、大模型、Java AI 与 Python 的学习笔记,以及 AI 平台的项目实战记录。

关于本站文章归档

登录

找回密码

注册