这个系列会把「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 张建好的表、正确的租户/数据权限白名单配置。
三个最容易出问题的地方再强调一次:
- MyBatis-Plus 要加
mybatis-plus-jsqlparser-4.9——漏加会导致分页静默失效 - 拦截器注册顺序不能改——数据权限 → 租户 → 分页
- 两张白名单都要登记——漏了在单租户环境测不出来
下一篇讲组卷抽题:完整实现规则抽题算法、处理”预览说够抽取却不够”,以及按组织批量指派考生。

AI项目博客







干货满满!