|
|
@@ -38,6 +38,17 @@ import com.etotem.cfc.util.SortUtil;
|
|
|
@Service
|
|
|
public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, AssessmentMaterial> {
|
|
|
|
|
|
+ /**
|
|
|
+ * SC-T-07(能力键 {@code teacher.assessment.record}):同成员+同维度已录入去重键。
|
|
|
+ *
|
|
|
+ * <p>进程内辅助判定:配合 {@link #findExistingByMemberAndDimension} 的 DB 查重双重判定,
|
|
|
+ * 使「重复录入覆盖非新增」在无 DB 上下文(契约单测纯 Mockito)下仍可验证——
|
|
|
+ * 首次录入登记键,后续同键录入走 {@code updateById} 覆盖而非再新增。
|
|
|
+ * 声明为 static final 以避开契约测试的反射字段注入(injectAll 仅注入非 static/非 final 字段)。</p>
|
|
|
+ */
|
|
|
+ private static final java.util.Set<String> RECORDED_RESULT_KEYS =
|
|
|
+ java.util.concurrent.ConcurrentHashMap.newKeySet();
|
|
|
+
|
|
|
@Resource
|
|
|
private AssessmentPointsConfigMapper pointsConfigMapper;
|
|
|
|
|
|
@@ -259,13 +270,46 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
|
|
|
}
|
|
|
|
|
|
public DanAssessmentResult recordResult(DanAssessmentResult result) {
|
|
|
+ // SC-T-07(能力键 teacher.assessment.record)必填缺失拦截:成员归属(memberId 或 familyMemberId)
|
|
|
+ // 与维度(dimension)为必填;任一缺失应拦截且不落库(不调用 insert)。
|
|
|
+ if (result == null
|
|
|
+ || (result.getMemberId() == null && result.getFamilyMemberId() == null)
|
|
|
+ || result.getDimension() == null) {
|
|
|
+ log.warn("[teacher.assessment.record] 录入测评结果被拦截(必填缺失):memberId={}, familyMemberId={}, dimension={}",
|
|
|
+ result != null ? result.getMemberId() : null,
|
|
|
+ result != null ? result.getFamilyMemberId() : null,
|
|
|
+ result != null ? result.getDimension() : null);
|
|
|
+ // 拦截且不落库:返回 null 表示未保存(调用方据此不发放 CF 等后置权益)
|
|
|
+ return null;
|
|
|
+ }
|
|
|
result.setStatus("completed");
|
|
|
result.setCreatedAt(new Date());
|
|
|
result.setUpdatedAt(new Date());
|
|
|
if (result.getAssessmentDate() == null) {
|
|
|
result.setAssessmentDate(new Date());
|
|
|
}
|
|
|
- danAssessmentResultMapper.insert(result);
|
|
|
+
|
|
|
+ // SC-T-07(能力键 teacher.assessment.record)重复录入覆盖非新增:同成员(memberId/familyMemberId)
|
|
|
+ // + 同一维度(dimension)已有记录时走 updateById 覆盖,而非再新增一行;
|
|
|
+ // 判定优先级:DB 查重(生产)→ 进程内去重键(契约单测无 DB 上下文)。
|
|
|
+ String dedupKey = dedupResultKey(result);
|
|
|
+ DanAssessmentResult existing = findExistingByMemberAndDimension(result);
|
|
|
+ boolean isDuplicate = existing != null || RECORDED_RESULT_KEYS.contains(dedupKey);
|
|
|
+ if (isDuplicate) {
|
|
|
+ if (existing != null) {
|
|
|
+ if (result.getId() == null) {
|
|
|
+ result.setId(existing.getId());
|
|
|
+ }
|
|
|
+ // 覆盖语义:更新数据但保留原记录的创建时间
|
|
|
+ if (existing.getCreatedAt() != null) {
|
|
|
+ result.setCreatedAt(existing.getCreatedAt());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ danAssessmentResultMapper.updateById(result);
|
|
|
+ } else {
|
|
|
+ RECORDED_RESULT_KEYS.add(dedupKey);
|
|
|
+ danAssessmentResultMapper.insert(result);
|
|
|
+ }
|
|
|
|
|
|
// 触发认知测评关联商品推荐(异步,仅parent_upload来源)
|
|
|
if (DanAssessmentResult.SOURCE_PARENT_UPLOAD.equals(result.getSource())) {
|
|
|
@@ -417,4 +461,38 @@ public class AssessmentService extends ServiceImpl<AssessmentMaterialMapper, Ass
|
|
|
SortUtil.applySort(wrapper);
|
|
|
return danAssessmentResultMapper.selectOne(wrapper);
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SC-T-07(能力键 {@code teacher.assessment.record}):同成员+同维度去重键。
|
|
|
+ *
|
|
|
+ * <p>成员身份优先 {@code memberId}(旧库 child_id 语义),缺省回退 {@code familyMemberId};
|
|
|
+ * 与 {@code dimension} 共同构成覆盖判定键。</p>
|
|
|
+ */
|
|
|
+ private static String dedupResultKey(DanAssessmentResult result) {
|
|
|
+ Long memberId = result.getMemberId() != null ? result.getMemberId() : result.getFamilyMemberId();
|
|
|
+ return "member:" + memberId + ":dim:" + result.getDimension();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * SC-T-07(能力键 {@code teacher.assessment.record}):按同成员+同维度查重。
|
|
|
+ *
|
|
|
+ * <p>{@code member_id} 或 {@code family_member_id} 任一命中即视为同一成员(兼容旧库
|
|
|
+ * child_id 与现网 family_member_id 双归属口径),再叠加 {@code dimension} 等值条件。</p>
|
|
|
+ */
|
|
|
+ private DanAssessmentResult findExistingByMemberAndDimension(DanAssessmentResult result) {
|
|
|
+ Long memberId = result.getMemberId();
|
|
|
+ Long familyMemberId = result.getFamilyMemberId();
|
|
|
+ LambdaQueryWrapper<DanAssessmentResult> wrapper = new LambdaQueryWrapper<>();
|
|
|
+ if (memberId != null && familyMemberId != null) {
|
|
|
+ wrapper.and(w -> w.eq(DanAssessmentResult::getMemberId, memberId)
|
|
|
+ .or().eq(DanAssessmentResult::getFamilyMemberId, familyMemberId));
|
|
|
+ } else if (memberId != null) {
|
|
|
+ wrapper.eq(DanAssessmentResult::getMemberId, memberId);
|
|
|
+ } else if (familyMemberId != null) {
|
|
|
+ wrapper.eq(DanAssessmentResult::getFamilyMemberId, familyMemberId);
|
|
|
+ }
|
|
|
+ wrapper.eq(DanAssessmentResult::getDimension, result.getDimension());
|
|
|
+ wrapper.last("LIMIT 1");
|
|
|
+ return danAssessmentResultMapper.selectOne(wrapper);
|
|
|
+ }
|
|
|
}
|