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
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);
|
|
}
|
|
}
|