You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

585 lines
29 KiB

package com.mydreamjob.ability;
import com.fasterxml.jackson.databind.JsonNode;
import com.mydreamjob.ability.dto.AbilityAttemptResponse;
import com.mydreamjob.ability.dto.AbilityHistoryItem;
import com.mydreamjob.ability.dto.AbilityResultResponse;
import com.mydreamjob.ability.dto.CreateAbilityAttemptRequest;
import com.mydreamjob.ability.dto.SaveAbilityAnswersRequest;
import com.mydreamjob.audit.AuditContext;
import com.mydreamjob.audit.AuditService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Clock;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AbilityAttemptService {
private static final int MAX_PAGE_SIZE = 50;
private static final String[] DIMENSIONS = {"LOGIC", "COMMUNICATION", "EXECUTION", "LEARNING"};
private final AbilityDefinitionRepository definitionRepository;
private final AbilityQuestionRepository questionRepository;
private final AbilityAttemptRepository attemptRepository;
private final AbilityAttemptQuestionRepository attemptQuestionRepository;
private final AbilityAnswerRepository answerRepository;
private final AbilityResultRepository resultRepository;
private final AbilityResultService resultService;
private final AbilityResultComputer resultComputer;
private final AbilityAttemptWriter attemptWriter;
private final AbilitySnapshotCodec snapshotCodec;
private final UserRepository userRepository;
private final AuditService auditService;
private final Clock clock;
/** R01-D4-1:能力测评提交成功后登记评分快照刷新(ABILITY 维度)。 */
private final CandidateScoringRefreshRequestService scoringRefreshRequestService;
// ─────────────────────────── 创建答卷 ───────────────────────────
@Transactional(isolation = Isolation.READ_COMMITTED)
public AbilityAttemptResponse create(Long userId, CreateAbilityAttemptRequest req, AuditContext ctx) {
requireStudent(userId);
AbilityDefinition def = definitionRepository.findByTypeCode(req.getTypeCode())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND));
if (!AbilityDefinition.STATUS_ACTIVE.equals(def.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_TYPE_NOT_FOUND);
}
LocalDateTime now = now();
String idem = normalizeIdempotencyKey(req.getIdempotencyKey());
if (idem != null) {
AbilityAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
// 原子释放本学生本类型已超时的遗留活跃答卷
releaseTimedOut(userId, def.getId(), now);
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE);
}
// 有效结果冷却期:存在未过期结果时不可重测
AbilityAttempt recentCompleted = attemptRepository
.findByStudentUserIdAndDefinitionIdAndStatusOrderByCompletedAtDesc(
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED)
.stream().findFirst().orElse(null);
if (recentCompleted != null) {
AbilityResult r = resultRepository.findByAttemptId(recentCompleted.getId()).orElse(null);
if (r != null && r.getValidUntil() != null && now.isBefore(r.getValidUntil())) {
throw new BusinessException(ErrorCode.ABILITY_RETAKE_COOLDOWN);
}
}
// 滚动窗口内完成次数限制
int windowDays = def.getRetakeWindowDays() == null ? 90 : def.getRetakeWindowDays();
int maxCompleted = def.getRetakeMaxCompleted() == null ? 3 : def.getRetakeMaxCompleted();
long completedInWindow = attemptRepository
.countByStudentUserIdAndDefinitionIdAndStatusAndCompletedAtGreaterThanEqual(
userId, def.getId(), AbilityAttempt.STATUS_COMPLETED, now.minusDays(windowDays));
if (completedInWindow >= maxCompleted) {
throw new BusinessException(ErrorCode.ABILITY_RETAKE_LIMIT);
}
List<AbilityQuestion> all = questionRepository
.findByDefinitionIdAndActiveTrueOrderByPositionAsc(def.getId());
List<AbilityQuestion> sampled = sample(all, def);
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
AbilityAttempt attempt;
try {
attempt = attemptWriter.persistNew(userId, def, sampled, idem, activeKeyOf(userId, def.getId()));
} catch (DataIntegrityViolationException e) {
if (idem != null) {
AbilityAttempt existing = findByIdem(userId, idem);
if (existing != null) {
if (!def.getId().equals(existing.getDefinitionId())) {
throw new BusinessException(ErrorCode.ABILITY_IDEMPOTENCY_CONFLICT);
}
return toAttemptResponse(existing);
}
}
if (attemptRepository.existsByStudentUserIdAndDefinitionIdAndStatusIn(
userId, def.getId(), List.of(AbilityAttempt.STATUS_DRAFT, AbilityAttempt.STATUS_IN_PROGRESS))) {
throw new BusinessException(ErrorCode.ABILITY_DUPLICATE_ACTIVE);
}
throw e;
}
auditService.log(userId, "ABILITY_ATTEMPT_CREATED", "ABILITY_ATTEMPT", attempt.getId(),
"attemptId=" + attempt.getId() + ", typeCode=" + def.getTypeCode()
+ ", status=" + attempt.getStatus() + ", answeredCount=0"
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + def.getRulesVersion(),
ctx);
return toAttemptResponse(attempt, def);
}
// ─────────────────────────── 答卷详情 ───────────────────────────
@Transactional
public AbilityAttemptResponse get(Long userId, Long attemptId) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
expireIfTimedOut(attempt);
return toAttemptResponse(attempt);
}
// ─────────────────────────── 查看结果 ───────────────────────────
@Transactional(readOnly = true)
public AbilityResultResponse loadResult(Long userId, Long attemptId) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
AbilityResult result = resultRepository.findByAttemptId(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_RESULT_NOT_FOUND));
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return resultService.toResponse(attempt, result, def, now());
}
// ─────────────────────────── 保存答案 ───────────────────────────
@Transactional
public AbilityAttemptResponse saveAnswers(Long userId, Long attemptId,
SaveAbilityAnswersRequest req, AuditContext ctx) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
// 同一 PATCH 内不得重复 attemptQuestionId
if (req.getAnswers().size() != req.getAnswers().stream()
.map(a -> a.getAttemptQuestionId()).distinct().count()) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "duplicate attemptQuestionId in answers");
}
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attemptId);
Map<Long, AbilityAttemptQuestion> aqById = aqs.stream()
.collect(Collectors.toMap(AbilityAttemptQuestion::getId, Function.identity()));
// 先整体校验:attemptQuestionId 属于本答卷、选项存在于快照;任一无效 → 整体拒绝,不启动计时
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) {
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId());
if (aq == null || item.getSelectedOptionId() == null) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "attemptQuestionId not in this attempt");
}
if (!snapshotCodec.optionExists(aq.getQuestionSnapshotJson(), item.getSelectedOptionId())) {
throw new BusinessException(ErrorCode.ABILITY_ANSWER_INVALID, "optionId does not exist");
}
}
LocalDateTime now = now();
boolean startTimer = attempt.getStartedAt() == null;
// 批量加载本请求涉及题目的既有答案,避免对每题逐条查询(消除 N+1)
List<Long> questionIds = req.getAnswers().stream()
.map(SaveAbilityAnswersRequest.AnswerItem::getAttemptQuestionId).toList();
Map<Long, AbilityAnswer> existingByAqId = answerRepository
.findByAttemptIdAndAttemptQuestionIdIn(attemptId, questionIds).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity()));
List<AbilityAnswer> toSave = new ArrayList<>(questionIds.size());
for (SaveAbilityAnswersRequest.AnswerItem item : req.getAnswers()) {
AbilityAttemptQuestion aq = aqById.get(item.getAttemptQuestionId());
Integer awarded = snapshotCodec.awardedScore(aq.getQuestionSnapshotJson(), item.getSelectedOptionId());
AbilityAnswer answer = existingByAqId.get(aq.getId());
if (answer == null) {
answer = AbilityAnswer.builder().attemptId(attemptId).attemptQuestionId(aq.getId()).build();
}
answer.setSelectedOptionId(item.getSelectedOptionId());
answer.setAwardedScore(awarded == null ? 0 : awarded);
toSave.add(answer);
}
answerRepository.saveAll(toSave);
answerRepository.flush();
int answered = (int) answerRepository.countByAttemptId(attemptId);
attempt.setAnsweredCount(answered);
if (startTimer && answered > 0) {
attempt.setStartedAt(now);
int limit = def == null || def.getTimeLimitMinutes() == null ? 30 : def.getTimeLimitMinutes();
attempt.setExpiresAt(now.plusMinutes(limit));
attempt.setStatus(AbilityAttempt.STATUS_IN_PROGRESS);
}
try {
attemptRepository.saveAndFlush(attempt);
} catch (OptimisticLockingFailureException e) {
throw new BusinessException(ErrorCode.ABILITY_CONCURRENT_UPDATE);
}
return toAttemptResponse(attempt, def);
}
// ─────────────────────────── 提交答卷 ───────────────────────────
@Transactional
public AbilityResultResponse submit(Long userId, Long attemptId, AuditContext ctx) {
requireStudent(userId);
AbilityAttempt attempt = loadOwned(userId, attemptId);
if (AbilityAttempt.STATUS_COMPLETED.equals(attempt.getStatus())) {
return idempotentResult(attempt);
}
if (expireIfTimedOut(attempt)) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
if (!isMutable(attempt.getStatus())) {
throw new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE);
}
int answered = (int) answerRepository.countByAttemptId(attemptId);
int total = totalOf(attempt);
if (answered < total || !perDimensionAnswered(attemptId)) {
throw new BusinessException(ErrorCode.ABILITY_INCOMPLETE);
}
int transitioned = attemptRepository.markCompleted(attemptId, now(), answered);
if (transitioned == 0) {
return idempotentResult(attempt);
}
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
AbilityResultComputer.ComputedResult computed = compute(attempt);
AbilityResult result;
try {
result = resultRepository.save(resultService.persist(attempt, def, computed, now()));
} catch (DataIntegrityViolationException e) {
return idempotentResult(attempt);
}
auditService.log(userId, "ABILITY_ATTEMPT_SUBMITTED", "ABILITY_ATTEMPT", attemptId,
"attemptId=" + attemptId + ", typeCode=" + (def == null ? null : def.getTypeCode())
+ ", status=COMPLETED, answeredCount=" + answered
+ ", questionSetVersion=" + attempt.getQuestionSetVersion()
+ ", rulesVersion=" + (def == null ? null : def.getRulesVersion()),
ctx);
// R01-D4-1:能力测评提交成功(新有效结果)影响 ABILITY 维度 → 登记当前周期刷新。
scoringRefreshRequestService.requestRefresh(userId, now());
return resultService.toResponse(attempt, result, def, now());
}
// ─────────────────────────── 我的历史 ───────────────────────────
@Transactional(readOnly = true)
public Page<AbilityHistoryItem> listMine(Long userId, int page, int size) {
requireStudent(userId);
if (page < 0 || size < 1 || size > MAX_PAGE_SIZE) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
LocalDateTime now = now();
Page<AbilityAttempt> attempts = attemptRepository
.findByStudentUserIdOrderByCreatedAtDesc(userId, PageRequest.of(page, size));
List<AbilityAttempt> content = attempts.getContent();
Map<Long, AbilityResult> resultByAttempt = resultRepository
.findByAttemptIdIn(content.stream().map(AbilityAttempt::getId).toList()).stream()
.collect(Collectors.toMap(AbilityResult::getAttemptId, Function.identity()));
// 对当前页 definitionId 去重后批量加载,避免对每条历史逐个 findById(消除 N+1)
List<Long> defIds = content.stream().map(AbilityAttempt::getDefinitionId).distinct().toList();
Map<Long, AbilityDefinition> defById = defIds.isEmpty() ? Map.of()
: definitionRepository.findAllById(defIds).stream()
.collect(Collectors.toMap(AbilityDefinition::getId, Function.identity()));
return attempts.map(a -> {
AbilityResult r = resultByAttempt.get(a.getId());
AbilityDefinition d = defById.get(a.getDefinitionId());
String validity = null;
if (r != null && r.getValidUntil() != null) {
validity = now.isBefore(r.getValidUntil()) ? AbilityResultComputer.VALIDITY_VALID
: AbilityResultComputer.VALIDITY_EXPIRED;
}
int total = d == null || d.getAttemptQuestionCount() == null ? 24 : d.getAttemptQuestionCount();
return AbilityHistoryItem.builder()
.attemptId(a.getId())
.typeCode(d == null ? null : d.getTypeCode())
.status(a.getStatus())
.validityStatus(validity)
.overallScore(r == null ? null : r.getOverallScore())
.answeredCount(a.getAnsweredCount())
.completionRate(completionRate(a.getAnsweredCount(), total))
.startedAt(a.getStartedAt())
.expiresAt(a.getExpiresAt())
.completedAt(a.getCompletedAt())
.validUntil(r == null ? null : r.getValidUntil())
.build();
});
}
// ─────────────────────────── 内部工具 ───────────────────────────
private AbilityResultResponse idempotentResult(AbilityAttempt attempt) {
AbilityResult result = resultRepository.findByAttemptId(attempt.getId())
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_INVALID_STATE));
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return resultService.toResponse(attempt, result, def, now());
}
private AbilityResultComputer.ComputedResult compute(AbilityAttempt attempt) {
List<AbilityResultComputer.Answered> items = new ArrayList<>();
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, AbilityAnswer> answerByAq = answerRepository.findByAttemptId(attempt.getId()).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, Function.identity()));
for (AbilityAttemptQuestion aq : aqs) {
AbilityAnswer ans = answerByAq.get(aq.getId());
if (ans == null) {
continue;
}
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson());
items.add(new AbilityResultComputer.Answered(
text(snap, AbilitySnapshotCodec.KEY_DIMENSION),
text(snap, AbilitySnapshotCodec.KEY_COMPETENCY_TAG),
text(snap, AbilitySnapshotCodec.KEY_STEM),
text(snap, AbilitySnapshotCodec.KEY_EXPLANATION),
ans.getAwardedScore(), aq.getId(), aq.getPosition(), ans.getSelectedOptionId()));
}
return resultComputer.compute(items, perDimOf(attempt));
}
private int totalOf(AbilityAttempt attempt) {
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return def == null || def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount();
}
private int perDimOf(AbilityAttempt attempt) {
AbilityDefinition def = definitionRepository.findById(attempt.getDefinitionId()).orElse(null);
return def == null || def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
}
private String text(JsonNode snap, String key) {
JsonNode n = snap.path(key);
return n.isMissingNode() || n.isNull() ? null : n.asText();
}
private boolean perDimensionAnswered(Long attemptId) {
// 一次查询该答题的题目,一次批量查询该答题的全部答案,避免对每题逐条查询(消除 N+1)
List<AbilityAttemptQuestion> aqs =
attemptQuestionRepository.findByAttemptIdOrderByPositionAsc(attemptId);
if (aqs.isEmpty()) {
return false;
}
Set<Long> answeredAqIds = answerRepository.findByAttemptId(attemptId).stream()
.map(AbilityAnswer::getAttemptQuestionId).collect(Collectors.toSet());
Map<String, Integer> counts = new LinkedHashMap<>();
for (AbilityAttemptQuestion aq : aqs) {
if (answeredAqIds.contains(aq.getId())) {
String dim = snapshotCodec.text(aq.getQuestionSnapshotJson(), AbilitySnapshotCodec.KEY_DIMENSION);
counts.merge(dim, 1, Integer::sum);
}
}
int required = 6;
for (String d : DIMENSIONS) {
if (counts.getOrDefault(d, 0) != required) {
return false;
}
}
return true;
}
private boolean expireIfTimedOut(AbilityAttempt attempt) {
if (isMutable(attempt.getStatus()) && attempt.getExpiresAt() != null
&& !now().isBefore(attempt.getExpiresAt())) {
attemptRepository.markExpiredAndReleaseActive(attempt.getId());
attempt.setStatus(AbilityAttempt.STATUS_EXPIRED);
attempt.setActiveKey(null);
return true;
}
return false;
}
private void releaseTimedOut(Long userId, Long definitionId, LocalDateTime now) {
for (AbilityAttempt a : attemptRepository.findTimedOutActiveByUserAndDef(userId, definitionId, now)) {
attemptRepository.markExpiredAndReleaseActive(a.getId());
}
// 立即刷新旧状态以免 DELETE/REREAD 不一致:不做,仅在此事务内后续判断用 DB 最新
}
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt) {
return toAttemptResponse(attempt, definitionRepository.findById(attempt.getDefinitionId()).orElse(null));
}
private AbilityAttemptResponse toAttemptResponse(AbilityAttempt attempt, AbilityDefinition def) {
List<AbilityAttemptQuestion> aqs = attemptQuestionRepository
.findByAttemptIdOrderByPositionAsc(attempt.getId());
Map<Long, String> selectedByAq = answerRepository.findByAttemptId(attempt.getId()).stream()
.collect(Collectors.toMap(AbilityAnswer::getAttemptQuestionId, AbilityAnswer::getSelectedOptionId));
int total = totalOf(attempt);
int answered = attempt.getAnsweredCount() == null ? (int) selectedByAq.size() : attempt.getAnsweredCount();
List<AbilityAttemptResponse.AbilityAttemptQuestionView> questions = new ArrayList<>(aqs.size());
for (AbilityAttemptQuestion aq : aqs) {
JsonNode snap = snapshotCodec.parse(aq.getQuestionSnapshotJson());
String itemType = text(snap, AbilitySnapshotCodec.KEY_ITEM_TYPE);
String stem = text(snap, AbilitySnapshotCodec.KEY_STEM);
List<AbilityAttemptResponse.AbilityOptionView> options = new ArrayList<>();
JsonNode opts = snap.path(AbilitySnapshotCodec.KEY_OPTIONS);
if (opts.isArray()) {
for (JsonNode o : opts) {
options.add(AbilityAttemptResponse.AbilityOptionView.builder()
.optionId(o.path("optionId").asText()).text(o.path("text").asText()).build());
}
}
questions.add(AbilityAttemptResponse.AbilityAttemptQuestionView.builder()
.attemptQuestionId(aq.getId())
.position(aq.getPosition())
.itemType(itemType)
.stem(stem)
.options(options)
.selectedOptionId(selectedByAq.get(aq.getId()))
.build());
}
Long remaining = null;
if (attempt.getExpiresAt() != null) {
long secs = Duration.between(now(), attempt.getExpiresAt()).getSeconds();
remaining = Math.max(0, secs);
}
return AbilityAttemptResponse.builder()
.attemptId(attempt.getId())
.status(attempt.getStatus())
.answeredCount(answered)
.totalQuestions(total)
.completionRate(completionRate(answered, total))
.startedAt(attempt.getStartedAt())
.expiresAt(attempt.getExpiresAt())
.remainingSeconds(remaining)
.questions(questions)
.build();
}
/**
* 抽题:四维各若干道,且每维三个 competencyTag 各抽 2 道,共 def.attemptQuestionCount 道。
* 任一维度/标签可用题不足时直接拒绝创建(不静默减少)。
*/
private List<AbilityQuestion> sample(List<AbilityQuestion> all, AbilityDefinition def) {
int perDim = def.getQuestionsPerDimension() == null ? 6 : def.getQuestionsPerDimension();
int attemptCount = def.getAttemptQuestionCount() == null ? 24 : def.getAttemptQuestionCount();
int perTag = 2; // perDim = 3 tags × 2
long seed = new Random().nextLong();
Random random = new Random(seed);
Map<String, Map<String, List<AbilityQuestion>>> byDimTag = new LinkedHashMap<>();
for (AbilityQuestion q : all) {
byDimTag.computeIfAbsent(q.getDimension(), k -> new LinkedHashMap<>())
.computeIfAbsent(q.getCompetencyTag(), k -> new ArrayList<>()).add(q);
}
int collected = 0;
List<AbilityQuestion> picked = new ArrayList<>();
for (String dim : DIMENSIONS) {
Map<String, List<AbilityQuestion>> tags = byDimTag.get(dim);
if (tags == null || tags.size() < 3) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"insufficient competency tags in dimension " + dim);
}
List<AbilityQuestion> dimPicked = new ArrayList<>();
for (Map.Entry<String, List<AbilityQuestion>> e : tags.entrySet()) {
List<AbilityQuestion> group = new ArrayList<>(e.getValue());
if (group.size() < perTag) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"insufficient active questions for tag " + e.getKey());
}
Collections.shuffle(group, random);
dimPicked.addAll(group.subList(0, perTag));
}
if (dimPicked.size() != perDim) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"dimension " + dim + " sampled != " + perDim);
}
picked.addAll(dimPicked);
collected += dimPicked.size();
}
Collections.shuffle(picked, random);
if (collected != attemptCount || picked.size() != attemptCount) {
throw new BusinessException(ErrorCode.BAD_REQUEST, "sampled question count mismatch");
}
return picked;
}
private boolean isMutable(String status) {
return AbilityAttempt.STATUS_DRAFT.equals(status)
|| AbilityAttempt.STATUS_IN_PROGRESS.equals(status);
}
private AbilityAttempt loadOwned(Long userId, Long attemptId) {
AbilityAttempt a = attemptRepository.findById(attemptId)
.orElseThrow(() -> new BusinessException(ErrorCode.ABILITY_ATTEMPT_NOT_FOUND));
if (!a.getStudentUserId().equals(userId)) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
return a;
}
private void requireStudent(Long userId) {
User u = userRepository.findById(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND));
boolean isStudent = u.getRoles() != null && u.getRoles().contains(User.Role.STUDENT);
if (!isStudent) {
throw new BusinessException(ErrorCode.FORBIDDEN);
}
}
private AbilityAttempt findByIdem(Long userId, String idem) {
if (idem == null) {
return null;
}
return attemptRepository.findByStudentUserIdAndIdempotencyKey(userId, idem).orElse(null);
}
private String normalizeIdempotencyKey(String key) {
return key == null || key.isBlank() ? null : key.trim();
}
private String activeKeyOf(Long studentUserId, Long definitionId) {
return studentUserId + ":" + definitionId + ":ACTIVE";
}
private LocalDateTime now() {
return LocalDateTime.now(clock);
}
private BigDecimal completionRate(int answered, int total) {
if (total <= 0) {
return BigDecimal.ZERO.setScale(2);
}
return BigDecimal.valueOf(answered)
.multiply(BigDecimal.valueOf(100))
.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
}
}