上一篇搭好了工程和数据库。这一篇实现组卷和考生指派——从「题库里按规则抽题生成试卷」到「把考生圈进考场」,每个方法都能直接抄。
一、试卷的两条生成路径
试卷由 exam_paper 的 gen_mode 决定生成方式:
| gen_mode | 方式 | 数据来源 |
|---|---|---|
| 1 | 固定组卷 | 前端提交题目清单,逐题落 exam_paper_question |
| 2 | 规则抽题 | 先落 exam_paper_rule,再逐条规则抽题 |
两者的产物都落在 exam_paper_question,后续流程完全一致。分流入口:
private void buildAndPersistQuestions(ExamPaperEntity paper, ExamPaperSaveBO bo) {
PaperGenModeEnum genMode = PaperGenModeEnum.fromCode(paper.getGenMode());
if (genMode == null) {
throw new PlatformException("组卷方式不合法:" + paper.getGenMode());
}
// 重新生成的前提:先清掉旧的题目与规则明细
removePaperChildren(List.of(paper.getId()));
List<ExamPaperQuestionEntity> questions;
if (genMode == PaperGenModeEnum.FIXED) {
questions = buildByFixedQuestions(paper.getId(), bo.getFixedQuestions());
} else {
questions = buildByRules(paper, bo.getRules());
}
examPaperQuestionService.saveBatch(questions);
refreshSummary(paper, questions); // 回写题目数、总分,并校验及格分不超总分
}
固定组卷的逻辑很直白——校验题目存在、去重、按提交顺序编号落库:
private List<ExamPaperQuestionEntity> buildByFixedQuestions(Long paperId,
List<ExamPaperQuestionItemBO> items) {
if (CollectionUtils.isEmpty(items)) {
throw new PlatformException("请至少选择一道题目");
}
List<Long> questionIds = new ArrayList<>(items.size());
Set<Long> seen = new HashSet<>();
for (ExamPaperQuestionItemBO item : items) {
if (!seen.add(item.getQuestionId())) {
throw new PlatformException("题目重复选择,题目Id=" + item.getQuestionId());
}
questionIds.add(item.getQuestionId());
}
loadQuestions(questionIds); // 校验题目存在且处于启用状态
List<ExamPaperQuestionEntity> result = new ArrayList<>(items.size());
int sortNo = 1;
for (ExamPaperQuestionItemBO item : items) {
ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();
pq.setPaperId(paperId);
pq.setQuestionId(item.getQuestionId());
pq.setSortNo(sortNo++);
pq.setScore(item.getScore());
result.add(pq);
}
return result;
}

二、规则抽题:核心算法
一条规则描述一个抽题条款:某个知识点、某个题型、某个难度、抽多少道、每题多少分。规则先落库,再执行抽题:
private List<ExamPaperQuestionEntity> buildByRules(ExamPaperEntity paper, List<ExamPaperRuleBO> rules) {
if (CollectionUtils.isEmpty(rules)) {
throw new PlatformException("请至少配置一条抽题规则");
}
List<ExamPaperRuleEntity> ruleEntities = new ArrayList<>(rules.size());
int sortNo = 1;
for (ExamPaperRuleBO rule : rules) {
ExamPaperRuleEntity ruleEntity = new ExamPaperRuleEntity();
ruleEntity.setPaperId(paper.getId());
ruleEntity.setCategoryId(rule.getCategoryId());
ruleEntity.setQuestionType(rule.getQuestionType());
ruleEntity.setDifficulty(rule.getDifficulty());
ruleEntity.setQuestionCount(rule.getQuestionCount());
ruleEntity.setScorePer(rule.getScorePer());
ruleEntity.setSortNo(sortNo++);
ruleEntities.add(ruleEntity);
}
examPaperRuleService.saveBatch(ruleEntities);
return drawByRules(ruleEntities); // 落完规则立刻抽题定型
}
2.1 抽题主体
private List<ExamPaperQuestionEntity> drawByRules(List<ExamPaperRuleEntity> rules) {
List<ExamPaperQuestionEntity> result = new ArrayList<>();
// 跨规则去重:同一道题不能被两条规则同时抽中
Set<Long> picked = new LinkedHashSet<>();
int sortNo = 1;
// 知识点树只加载一次:N 条规则即 N 次全表查询,这里提前避免
CategoryTree tree = loadCategoryTree();
for (ExamPaperRuleEntity rule : rules) {
ExamRandomQuestionQueryBO query = new ExamRandomQuestionQueryBO();
query.setCategoryId(rule.getCategoryId());
// 规则选中的是知识点树的某个节点,抽题应覆盖其全部子知识点
query.setCategoryIds(tree.descendantIds(rule.getCategoryId()));
query.setQuestionType(rule.getQuestionType());
query.setDifficulty(rule.getDifficulty());
query.setLimit(rule.getQuestionCount());
int need = rule.getQuestionCount();
// 先算「排除已选题后」的可用题量,再取随机窗口起点
long available = examQuestionMapper.countAvailableForDraw(query, picked);
if (available < need) {
throw new PlatformException("抽题失败:" + describeRule(rule, tree)
+ " 可用题目不足,需要 " + need + " 道,实际仅 " + available + " 道");
}
int offset = available == need
? 0
: ThreadLocalRandom.current().nextInt((int) (available - need + 1));
List<Long> ids = examQuestionMapper.selectRandomQuestionIds(query, picked, offset);
if (ids.size() < need) {
throw new PlatformException("抽题失败:" + describeRule(rule, tree)
+ " 可用题目不足,需要 " + need + " 道,实际仅 " + ids.size() + " 道");
}
for (Long questionId : ids) {
ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();
pq.setPaperId(rule.getPaperId());
pq.setQuestionId(questionId);
pq.setSortNo(sortNo++);
pq.setScore(rule.getScorePer());
result.add(pq);
picked.add(questionId);
}
}
return result;
}
三个要点:
① 抽不到就抛错,不”少抽”。如果规则要 10 道、库里只有 7 道就静默抽 7 道,那这份卷子的总分和预期不符——考生答完发现满分不是 100,是最难排查的那类问题。宁可当场失败。
② 用随机窗口替代 ORDER BY RAND()。这是性能上的关键改动。ORDER BY RAND() 要为每一行生成随机值再整体排序,题库上万条时明显变慢。改用「先 COUNT 出可用量 → 取随机 offset → 按主键顺序 LIMIT」,走的是索引区间扫描。
③ picked 同时参与 COUNT 和 SELECT。跨规则去重意味着第二条规则要排除第一条已抽中的题。这里必须保证「算可用量」和「真抽取」用的是同一套排除条件,否则算出来的数字不是真能抽到的数量,随机 offset 会落到非法区间。
2.2 Mapper:共用筛选条件
上一节提到的两点,在 XML 里靠共用 SQL 片段保证:
<!-- 可用题筛选条件:抽题与「可用题量」共用同一段,避免口径分叉 -->
<sql id="availableQuestionFilters">
t.status = 1
<if test="bo != null">
<if test="bo.categoryIds != null and bo.categoryIds.size() > 0">
AND t.category_id IN
<foreach collection="bo.categoryIds" item="categoryId" open="(" separator="," close=")">
#{categoryId}
</foreach>
</if>
<if test="(bo.categoryIds == null or bo.categoryIds.size() == 0) and bo.categoryId != null">
AND t.category_id = #{bo.categoryId}
</if>
<if test="bo.questionType != null">
AND t.question_type = #{bo.questionType}
</if>
<if test="bo.difficulty != null">
AND t.difficulty = #{bo.difficulty}
</if>
</if>
</sql>
<!-- 剔除已被前面规则选中的题目(跨规则去重) -->
<sql id="availableQuestionExclude">
<if test="excludeIds != null and excludeIds.size() > 0">
AND t.id NOT IN
<foreach collection="excludeIds" item="excludedId" open="(" separator="," close=")">
#{excludedId}
</foreach>
</if>
</sql>
<!-- 抽题:按主键顺序取一个窗口(offset 由 Service 随机生成) -->
<select id="selectRandomQuestionIds" resultType="java.lang.Long">
SELECT t.id
FROM exam_question t
WHERE
<include refid="availableQuestionFilters"/>
<include refid="availableQuestionExclude"/>
ORDER BY t.id
LIMIT #{offset}, #{bo.limit}
</select>
<!-- 组卷专用可用题量:与 selectRandomQuestionIds 完全同条件 -->
<select id="countAvailableForDraw" resultType="java.lang.Long">
SELECT COUNT(*)
FROM exam_question t
WHERE
<include refid="availableQuestionFilters"/>
<include refid="availableQuestionExclude"/>
</select>
抽题顺序有序,会让考生看到有序的卷面吗?不会。因为考生进入考试时还会按 shuffle_question 开关整体打乱(见下一篇的组卷快照)。组卷阶段的有序只是为了 LIMIT 窗口能走索引。
2.3 知识点树:BFS 展开子树
规则里选的是知识点树的某个节点。如果只按该节点精确匹配,父节点上通常没有直接挂题,会得到 0 道而误报「题目不足」。所以必须展开整棵子树:
private CategoryTree loadCategoryTree() {
// 租户拦截器会自动限定到当前租户,不会跨租户取到知识点
List<ExamCategoryEntity> all = examCategoryMapper.selectList(null);
Map<Long, List<ExamCategoryEntity>> childrenByParent = all.stream()
.filter(item -> item.getParentId() != null && item.getId() != null)
.collect(Collectors.groupingBy(ExamCategoryEntity::getParentId));
Map<Long, String> nameById = all.stream()
.filter(item -> item.getId() != null)
.collect(Collectors.toMap(ExamCategoryEntity::getId, ExamCategoryEntity::getCategoryName,
(a, b) -> a));
return new CategoryTree(childrenByParent, nameById);
}
private record CategoryTree(Map<Long, List<ExamCategoryEntity>> childrenByParent,
Map<Long, String> nameById) {
/** 返回「指定知识点 + 其全部子孙知识点」的 id 集合 */
private List<Long> descendantIds(Long categoryId) {
if (categoryId == null) {
return null; // 保持调用方的「不限」语义
}
Set<Long> ids = new LinkedHashSet<>();
ids.add(categoryId);
// BFS 展开整棵子树;重复入队会被 ids.add 挡住,成环也不会死循环
Deque<ExamCategoryEntity> queue = new ArrayDeque<>(
childrenByParent.getOrDefault(categoryId, List.of()));
while (!queue.isEmpty()) {
ExamCategoryEntity current = queue.poll();
if (current.getId() != null && ids.add(current.getId())) {
queue.addAll(childrenByParent.getOrDefault(current.getId(), List.of()));
}
}
return new ArrayList<>(ids);
}
private String nameOf(Long categoryId) {
return categoryId == null ? null : nameById.get(categoryId);
}
}

图:题目管理,左侧知识点树,右侧按题型、难度、来源筛选。
2.4 重新抽题
public void regenerate(Long id) {
ExamPaperEntity entity = getOrThrow(id, "试卷不存在,ID=" + id);
if (PaperGenModeEnum.fromCode(entity.getGenMode()) != PaperGenModeEnum.RULE) {
throw new PlatformException("只有规则抽题的试卷才支持重新抽题");
}
// 已被已发布的考试引用时不允许换题,否则考生答的和统计的不是一套
checkReferencedByPublishedExam(List.of(id));
List<ExamPaperRuleEntity> rules = listRules(id);
if (rules.isEmpty()) {
throw new PlatformException("试卷未配置抽题规则,无法重新抽题");
}
removePaperQuestions(List.of(id));
List<ExamPaperQuestionEntity> questions = drawByRules(rules);
examPaperQuestionService.saveBatch(questions);
refreshSummary(entity, questions);
}

图:试卷列表,规则抽题生成的试卷可重新抽题、生成平行卷。
三、平行卷
平行卷(B 卷)的要求是结构对齐——知识点、题型、难度、分值都不变,只换具体题目。做法是逐题替换:
@Override
@Transactional(rollbackFor = Exception.class)
public ExamPaperParallelVO createParallel(Long sourcePaperId, ExamPaperParallelBO bo) {
ExamPaperEntity source = getOrThrow(sourcePaperId, "试卷不存在,ID=" + sourcePaperId);
List<ExamPaperQuestionVO> sourceQuestions =
examPaperQuestionMapper.selectPaperQuestions(sourcePaperId);
if (CollectionUtils.isEmpty(sourceQuestions)) {
throw new PlatformException("原试卷没有题目,无法生成平行卷");
}
checkNameUnique(bo.getPaperName(), null);
// 原题的「知识点/题型/难度」是对齐基准
List<Long> sourceQuestionIds = sourceQuestions.stream()
.map(ExamPaperQuestionVO::getQuestionId)
.filter(Objects::nonNull)
.distinct()
.toList();
Map<Long, ExamQuestionEntity> originIndex = examQuestionMapper.selectBatchIds(sourceQuestionIds)
.stream()
.filter(entity -> entity.getId() != null)
.collect(Collectors.toMap(ExamQuestionEntity::getId, entity -> entity, (a, b) -> a));
ExamPaperEntity paper = new ExamPaperEntity();
paper.setPaperName(bo.getPaperName().trim());
// 题目已逐题替换并定型,落为固定卷
paper.setGenMode(PaperGenModeEnum.FIXED.getCode());
paper.setPassScore(source.getPassScore());
paper.setDurationMin(source.getDurationMin());
paper.setRemark("由《" + source.getPaperName() + "》生成的平行卷");
paper.setQuestionCount(0);
paper.setTotalScore(BigDecimal.ZERO);
paper.setStatus(EnableStatusEnum.ENABLED.getCode());
save(paper);
CategoryTree tree = loadCategoryTree();
// 排除集合承担两件事:不换回原题、不重复使用同一道替换题
Set<Long> excludeIds = new LinkedHashSet<>(sourceQuestionIds);
List<ExamPaperQuestionEntity> questions = new ArrayList<>(sourceQuestions.size());
List<String> shortages = new ArrayList<>();
int sortNo = 1;
for (ExamPaperQuestionVO sourceQuestion : sourceQuestions) {
ExamQuestionEntity origin = originIndex.get(sourceQuestion.getQuestionId());
if (origin == null) {
shortages.add("第 " + sourceQuestion.getSortNo() + " 题的原题已被删除,无法对齐");
continue;
}
Long alternativeId = pickAlternative(origin, tree, excludeIds);
if (alternativeId == null) {
shortages.add("第 " + sourceQuestion.getSortNo() + " 题("
+ describeOrigin(origin, tree) + ")没有可替换的同结构题目");
continue;
}
excludeIds.add(alternativeId);
ExamPaperQuestionEntity pq = new ExamPaperQuestionEntity();
pq.setPaperId(paper.getId());
pq.setQuestionId(alternativeId);
pq.setSortNo(sortNo++);
pq.setScore(sourceQuestion.getScore()); // 分值沿用原卷
questions.add(pq);
}
if (!shortages.isEmpty()) {
// 整卷回滚:部分替换的「平行卷」结构已不对齐,比生成失败更危险
throw new PlatformException("平行卷生成失败:" + String.join(";", shortages)
+ "。请先补充题库后重试");
}
examPaperQuestionService.saveBatch(questions);
refreshSummary(paper, questions);
// ... 组装返回 VO(原卷名、新卷 Id、替换题数)
}
/** 为一道原题抽一道「同结构」的替换题 */
private Long pickAlternative(ExamQuestionEntity origin, CategoryTree tree, Set<Long> excludeIds) {
ExamRandomQuestionQueryBO query = new ExamRandomQuestionQueryBO();
query.setCategoryIds(tree.descendantIds(origin.getCategoryId()));
query.setQuestionType(origin.getQuestionType());
query.setDifficulty(origin.getDifficulty());
query.setLimit(1);
long available = examQuestionMapper.countAvailableForDraw(query, excludeIds);
if (available <= 0) {
return null;
}
// 随机起点,避免每次生成的平行卷都挑中同一批题
int offset = available == 1 ? 0 : ThreadLocalRandom.current().nextInt((int) available);
List<Long> ids = examQuestionMapper.selectRandomQuestionIds(query, excludeIds, offset);
return ids.isEmpty() ? null : ids.get(0);
}
「任一题找不到替换就整卷回滚」是这里最重要的决定。
如果改成「能找到几道换几道」,产出的卷子结构已经不对齐,却还会被当作 B 卷使用——两个考场的难度分布不同,成绩就没法横向比较。宁可让操作者知道”这批题不够出平行卷”,也不能悄悄交付一份有缺陷的卷子。
另外注意:平行卷是纯算法、零 token 成本,不调用大模型。这件事本质是”按条件再查一次”,模型帮不上忙,反而会引入不确定性。
四、参考名单指派
4.1 两种圈人方式
先看按组织批量指派:
@Override
@Transactional(rollbackFor = Exception.class)
public int addCandidatesByOrg(ExamCandidateOrgSaveBO bo) {
requireDraftExam(bo.getExamId()); // 已发布的考试不能再改名单
List<Long> orgIds = bo.getOrgIds().stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (orgIds.isEmpty()) {
throw new PlatformException("请至少选择一个组织");
}
// 组织(含子孙)→ 启用账号;显式带租户,避免跨租户拉人
List<Long> userIds = baseMapper.selectUserIdsByOrgIds(orgIds, UserUtils.getTenantId());
if (userIds.isEmpty()) {
throw new PlatformException("所选组织下没有可指派的启用账号");
}
ExamCandidateSaveBO saveBO = new ExamCandidateSaveBO();
saveBO.setExamId(bo.getExamId());
saveBO.setUserIds(userIds);
return addCandidates(saveBO); // 复用统一的落库逻辑
}
组织树的「含子孙」靠物化路径前缀匹配,一条 SQL 搞定,不用递归查询:
<!--
按组织(含子孙)取启用账号:sys_user / sys_user_org / sys_org 均未纳入
租户隔离表,故 tenantId 由 Java 侧显式注入。
子孙判定:ao.org_path 以「该组织 org_path + ,」为前缀。
-->
<select id="selectUserIdsByOrgIds" resultType="java.lang.Long">
SELECT DISTINCT u.id
FROM sys_user u
JOIN sys_user_org uo ON uo.user_id = u.id
<where>
u.status = 1
<if test="tenantId != null">
AND u.tenant_id = #{tenantId}
</if>
AND (
<foreach collection="orgIds" item="orgId" separator=" OR ">
(uo.org_id = #{orgId}
OR uo.org_path LIKE CONCAT((SELECT o.org_path FROM sys_org o WHERE o.id = #{orgId}), ',%'))
</foreach>
)
</where>
ORDER BY u.id ASC
</select>
4.2 统一的落库逻辑
两种圈人方式最终都汇到同一个方法:
@Override
@Transactional(rollbackFor = Exception.class)
public int addCandidates(ExamCandidateSaveBO bo) {
requireDraftExam(bo.getExamId());
// 1. 去重入参
List<Long> userIds = bo.getUserIds().stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (userIds.isEmpty()) {
throw new PlatformException("请至少选择一名考生");
}
// 2. 校验用户可用(存在 + 同租户 + 启用)
// 批量指派时 userIds 可能远超单条 IN 的长度上限,按 500 分片查询后合并
Long tenantId = UserUtils.getTenantId();
List<ExamCandidateUserVO> users = BatchQueryUtils.queryInBatches(
userIds, batch -> baseMapper.selectUsersByIds(batch, tenantId));
if (users.size() != userIds.size()) {
Set<Long> found = users.stream().map(ExamCandidateUserVO::getUserId).collect(Collectors.toSet());
List<Long> missing = userIds.stream().filter(id -> !found.contains(id)).toList();
throw new PlatformException("以下用户不存在或不属于当前租户:" + missing);
}
List<String> disabled = users.stream()
.filter(u -> !Objects.equals(1, u.getStatus()))
.map(u -> StringUtils.hasText(u.getRealName()) ? u.getRealName() : u.getUsername())
.toList();
if (!disabled.isEmpty()) {
throw new PlatformException("以下用户账号非启用状态,不能指派为考生:" + disabled);
}
// 3. 剔除已在名单中的人(重复指派不报错,按跳过处理)
Set<Long> existing = listCandidateUserIds(bo.getExamId());
List<ExamCandidateUserVO> toAdd = users.stream()
.filter(u -> !existing.contains(u.getUserId()))
.toList();
if (toAdd.isEmpty()) {
return 0;
}
// 4. 落库(姓名取快照,与用户后续改名解耦)
List<ExamCandidateEntity> entities = new ArrayList<>(toAdd.size());
for (ExamCandidateUserVO user : toAdd) {
ExamCandidateEntity entity = new ExamCandidateEntity();
entity.setExamId(bo.getExamId());
entity.setUserId(user.getUserId());
entity.setUserName(StringUtils.hasText(user.getRealName())
? user.getRealName() : user.getUsername());
entity.setStatus(CandidateStatusEnum.PENDING.getCode());
entities.add(entity);
}
saveBatch(entities);
return entities.size();
}
这里有个容易踩的坑:组织树可能上千人,逐行插入会产生大量数据库往返。所以流程是「先解析出完整用户集合 → 去重 → 校验租户与状态 → 成批写入」,而不是在循环里判断一行插一行。
另外 BatchQueryUtils.queryInBatches 是必要的——按组织拉人时 userIds 可能几千个,单条 IN 会超出 MySQL 的包大小限制或 SQL 长度限制。
4.3 Excel 名单导入
按「工号(优先)/ 登录名」匹配启用账号:
<!-- 查不到返回 null -->
<select id="selectUserIdByAccount" resultType="java.lang.Long">
SELECT u.id
FROM sys_user u
<where>
u.status = 1
<if test="tenantId != null">
AND u.tenant_id = #{tenantId}
</if>
<choose>
<when test="employeeNo != null and employeeNo != ''">
AND u.employee_no = #{employeeNo}
</when>
<otherwise>
AND u.username = #{username}
</otherwise>
</choose>
</where>
</select>
Excel 导入的关键是错误处理策略:逐行校验并收集错误行号,最后一次性成批指派。不要遇到第一行错误就中断——否则使用者要反复导入、不断修正。把所有问题行一次性列出来,改一轮就能过。
五、随机练习
练习模式(/practice)刻意和正式考试解耦:不建答卷、不入库、不参与成绩统计。但有两点必须复用:
① 抽题复用同一套逻辑,并且按同样口径把知识点展开为「自身 + 子孙」。如果练习的知识点展开逻辑和考试不一样,就会出现「练习覆盖面比考试窄」这种说不清的问题。
② 判题复用同一个 ExamAutoGrader——把题目包装成单题快照后交给判分器。
为什么一定要复用?如果练习另写一套字符串比较逻辑,早晚会出现「练习判对、考试判错」。考生会立刻怀疑整个系统的判分能力,这种信任损失很难挽回。
答案下发策略也做了区分:抽题接口不下发答案,只有逐题提交后才返回该题的答案与解析。不能一次性把整卷答案拉走,否则练习就成了”调接口拿答案”。
六、小结
这一篇实现的四个要点:
- 抽题用随机窗口而非
ORDER BY RAND()——先 COUNT 再取随机 offset,走索引避免全表排序 - 「可用量」与「真抽取」共用同一段 SQL——口径分叉会让随机 offset 落到非法区间
- 平行卷任一题找不到替换就整卷回滚——部分替换的卷子结构不对齐,比生成失败更危险
- 圈人先解析再批量写——组织可能上千人,逐行插入的往返开销不可接受
另外两个容易漏的隔离点:sys_org 和 sys_user 这类平台共享表没有纳入租户隔离,凡是直接查它们的地方都必须显式加 tenant_id 条件。
下一篇讲考试进行时:断线续考、交卷防重、防作弊,以及两个真实的实现陷阱。

AI项目博客






