--- /dev/null +++ b/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishSegmentOutputIdentityRecoveryJobHandler.java @@ -0,0 +1,33 @@ +package cn.jojo.cc.worker.task; + +import cn.jojo.cc.biz.service.english.segment.EnglishSegmentOutputIdentityRecoveryBizService; +import cn.jojo.cc.common.enums.RedisKeyEnum; +import com.xxl.job.core.biz.model.ReturnT; +import com.xxl.job.core.handler.annotation.XxlJob; +import jakarta.annotation.Resource; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Component; +import java.util.concurrent.TimeUnit; + +/** 迁移/修复作业,双库每轮各50题,真实输出重生成会写OSS,部署后人工启用。 */ +@Component +public class EnglishSegmentOutputIdentityRecoveryJobHandler { + @Resource private EnglishSegmentOutputIdentityRecoveryBizService service; + @Resource private RedissonClient redissonClient; + + @XxlJob("englishSegmentOutputIdentityRecover") + public ReturnT recover(String ignored) { + var lock = redissonClient.getLock(RedisKeyEnum.ENGLISH_SEGMENT_REFRESH_LOCK.buildKey()); + boolean acquired = false; + try { + acquired = lock.tryLock(0L, TimeUnit.MILLISECONDS); + if (!acquired) { return new ReturnT<>("分词刷新/来源恢复正在执行"); } + return new ReturnT<>(service.runOnce(BaseScheduleJobHandler.SYSTEM)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return new ReturnT<>(ReturnT.FAIL_CODE, "interrupted"); + } finally { + if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } + } + } +} --- /dev/null +++ b/api/api-server/src/main/java/cn/jojo/cc/server/service/impl/QuestionServiceImpl.java @@ -0,0 +1,3677 @@ +package cn.jojo.cc.server.service.impl; + +import cn.jojo.cc.api.domain.request.KnowledgeLabelReq; +import cn.jojo.cc.api.domain.request.QuestionIdsReq; +import cn.jojo.cc.api.domain.request.QuestionLibraryTagQueryReq; +import cn.jojo.cc.api.domain.request.QuestionUpdateTimeReq; +import cn.jojo.cc.api.domain.request.QuestionsReq; +import cn.jojo.cc.api.domain.request.frame.resource.ListFrameResourceReq; +import cn.jojo.cc.api.domain.request.question.biz.GetQuestionJsonReq; +import cn.jojo.cc.api.domain.request.question.biz.ListMetaQuestionsByKeysReq; +import cn.jojo.cc.api.domain.request.question.biz.ListSameQuestionsReq; +import cn.jojo.cc.api.domain.request.question.biz.ListSubQuestionsReq; +import cn.jojo.cc.api.domain.request.question.library.ListLessonSameQuestionsReq; +import cn.jojo.cc.api.domain.request.question.library.ListQuestionByKeysReq; +import cn.jojo.cc.api.domain.request.question.library.ListQuestionReportReq; +import cn.jojo.cc.api.domain.request.question.library.ListStandardQuestionReq; +import cn.jojo.cc.api.domain.request.question.library.ListTestSameQuestionsReq; +import cn.jojo.cc.api.domain.request.question.library.ListVerifyQuestionReq; +import cn.jojo.cc.api.domain.request.question.library.PrintableQuestionReq; +import cn.jojo.cc.api.domain.request.question.library.StandardQuestionUniqueKeyReq; +import cn.jojo.cc.api.service.FrameResourceService; +import cn.jojo.cc.api.service.QuestionService; +import cn.jojo.cc.api.service.TagLibraryService; +import cn.jojo.cc.biz.cache.BusinessSchemaCacheService; +import cn.jojo.cc.biz.cache.CollectionCacheService; +import cn.jojo.cc.biz.cache.CollectionElementCacheService; +import cn.jojo.cc.biz.cache.CombinationQuestionCacheService; +import cn.jojo.cc.biz.cache.ContentNormConfigCacheService; +import cn.jojo.cc.biz.cache.FormAndDataCacheService; +import cn.jojo.cc.biz.cache.FormDataCacheService; +import cn.jojo.cc.biz.cache.GameSkinCacheService; +import cn.jojo.cc.biz.cache.InteractiveFormCacheService; +import cn.jojo.cc.biz.cache.QuestionBaseInfoCacheService; +import cn.jojo.cc.biz.cache.QuestionCacheService; +import cn.jojo.cc.biz.cache.QuestionContentByIdCacheService; +import cn.jojo.cc.biz.cache.QuestionKeyIdCacheService; +import cn.jojo.cc.biz.cache.QuestionLibraryUpdateTimeCacheService; +import cn.jojo.cc.biz.cache.QuestionTypeTemplateCacheService; +import cn.jojo.cc.biz.cache.QuestionUpdateTimeCacheService; +import cn.jojo.cc.biz.cache.StepComponentCacheService; +import cn.jojo.cc.biz.cache.TemplateLayoutCacheService; +import cn.jojo.cc.biz.cache.key.BusinessSchemaCacheKey; +import cn.jojo.cc.biz.cache.key.FormAndDataCacheKey; +import cn.jojo.cc.biz.cache.key.FormDataCacheKey; +import cn.jojo.cc.biz.model.bo.ClientResourcePackageBo; +import cn.jojo.cc.biz.model.bo.question.library.KnowledgeLabelBo; +import cn.jojo.cc.biz.model.bo.question.library.QuestionEnhanceRemoteResponseBo; +import cn.jojo.cc.biz.model.cache.BusinessSchemaCacheBo; +import cn.jojo.cc.biz.model.cache.FormAndDataCacheBo; +import cn.jojo.cc.biz.model.cache.FormDataCacheBo; +import cn.jojo.cc.biz.model.cache.GameSkinCacheBo; +import cn.jojo.cc.biz.model.cache.InteractiveFormCacheBo; +import cn.jojo.cc.biz.model.cache.QuestionBaseInfoCacheBo; +import cn.jojo.cc.biz.model.cache.QuestionTypeTemplateCacheBo; +import cn.jojo.cc.biz.model.cache.StepComponentBo; +import cn.jojo.cc.biz.model.cache.TemplateLayoutCacheBo; +import cn.jojo.cc.biz.service.DictService; +import cn.jojo.cc.biz.service.QuestionTypeBizService; +import cn.jojo.cc.biz.service.component.CCBResourceService; +import cn.jojo.cc.biz.service.english.segment.EnglishSegmentDeliveryBizService; +import cn.jojo.cc.biz.service.module.step.StepComponentBizService; +import cn.jojo.cc.biz.service.question.QuestionBizService; +import cn.jojo.cc.biz.service.question.produce.QuestionProduceBizService; +import cn.jojo.cc.biz.utils.JsonStoreUtil; +import cn.jojo.cc.biz.utils.TenantUtils; +import cn.jojo.cc.biz.utils.UriToolsUtil; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaExtractDataUtil; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaUtil; +import cn.jojo.cc.common.constants.Constants; +import cn.jojo.cc.common.dto.KnowledgeLabelDto; +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import cn.jojo.cc.common.dto.QuestionDto; +import cn.jojo.cc.common.dto.QuestionEnhanceDto; +import cn.jojo.cc.common.dto.QuestionJsonUrlDto; +import cn.jojo.cc.common.dto.QuestionUpdateTimeDto; +import cn.jojo.cc.common.dto.UnityResourceDto; +import cn.jojo.cc.common.dto.frame.resource.FrameResourceDto; +import cn.jojo.cc.common.dto.module.QuestionGroupDto; +import cn.jojo.cc.common.dto.printable.PrintableQuestionDto; +import cn.jojo.cc.common.dto.question.library.BusinessSameQuestionsDto; +import cn.jojo.cc.common.dto.question.library.ExistLessonSameQuestionsDto; +import cn.jojo.cc.common.dto.question.library.LessonSameQuestionsDto; +import cn.jojo.cc.common.dto.question.library.MetaQuestionSetDto; +import cn.jojo.cc.common.dto.question.library.QuestionDefinitionDto; +import cn.jojo.cc.common.dto.question.library.QuestionForSimpleDto; +import cn.jojo.cc.common.dto.question.library.QuestionSetDto; +import cn.jojo.cc.common.dto.question.library.StandardQuestionDataDto; +import cn.jojo.cc.common.dto.question.library.StandardQuestionDto; +import cn.jojo.cc.common.dto.question.library.StandardQuestionInfoDto; +import cn.jojo.cc.common.dto.question.library.VerifyGroupQuestionDto; +import cn.jojo.cc.common.dto.question.library.VerifyQuestionDto; +import cn.jojo.cc.common.dto.question.report.QuestionReportDto; +import cn.jojo.cc.common.dto.tag_library.TagDto; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.ElementRoleEnum; +import cn.jojo.cc.common.enums.QuestionRequireStateEnum; +import cn.jojo.cc.common.enums.YesOrNoEnum; +import cn.jojo.cc.common.enums.collection.ElementTypeEnum; +import cn.jojo.cc.common.enums.dict.QuestionTypeEnum; +import cn.jojo.cc.common.enums.question.BusinessSchemaRelTypeEnum; +import cn.jojo.cc.common.enums.question.BusinessTypeClassifyEnum; +import cn.jojo.cc.common.enums.question.FormDataBusinessTypeEnum; +import cn.jojo.cc.common.enums.question.OutPutDataTypeEnum; +import cn.jojo.cc.common.enums.question.QuestionQueryBusinessTypeEnum; +import cn.jojo.cc.common.enums.question.QuestionTypeCategoryEnum; +import cn.jojo.cc.common.exception.BusinessException; +import cn.jojo.cc.common.utils.MessageSourceUtils; +import cn.jojo.cc.common.utils.RpcResultUtil; +import cn.jojo.cc.core.bo.QuestionUpdateTimeBo; +import cn.jojo.cc.core.bo.question.library.SearchQuestionBo; +import cn.jojo.cc.core.domain.collection.CollectionBusinessDo; +import cn.jojo.cc.core.domain.collection.CollectionElementDo; +import cn.jojo.cc.core.domain.question.library.QuestionContentDo; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import cn.jojo.cc.core.domain.question.library.QuestionJsonDo; +import cn.jojo.cc.core.domain.question.library.QuestionRequireDo; +import cn.jojo.cc.core.enums.collection.BusinessTypeEnum; +import cn.jojo.cc.core.service.CollectionBusinessCoreService; +import cn.jojo.cc.core.service.CollectionElementCoreService; +import cn.jojo.cc.core.service.question.library.QuestionCoreService; +import cn.jojo.cc.core.service.question.library.QuestionJsonCoreService; +import cn.jojo.cc.server.common.utils.ValidatorUtils; +import cn.jojo.cc.server.component.CdnListReplaceUtil; +import cn.jojo.cc.server.config.ApiServerConfig; +import cn.jojo.cc.server.convert.ExamPaperSourceBo2DtoConvert; +import cn.jojo.cc.server.convert.KnowledgeLabelBo2DtoConvert; +import cn.jojo.cc.server.convert.QuestionEnhanceBo2DtoConvert; +import cn.jojo.cc.server.manager.QuestionManager; +import cn.jojo.cc.server.model.DataSourceRouterWrapper; +import cn.jojo.cc.utils.CourseCenterUtil; +import cn.jojo.infra.sdk.api.metadata.DefaultRpcResult; +import cn.jojo.infra.sdk.api.metadata.IRpcResult; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.collect.Lists; +import com.google.common.collect.Maps; +import com.google.common.collect.Sets; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.collections4.ListUtils; +import org.apache.commons.collections4.MapUtils; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.apache.dubbo.apidocs.annotations.ApiDoc; +import org.apache.dubbo.apidocs.annotations.ApiModule; +import org.apache.dubbo.config.annotation.Service; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cglib.beans.BeanCopier; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.UnaryOperator; +import java.util.stream.Collectors; + +import static cn.jojo.cc.server.common.utils.ValidatorUtils.BUSINESS_SCENES_NOT_NULL; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.BUSINESS_SCENES_NOT_SUPPORT; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.COURSE_ID_NOT_NULL; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.FIFTY; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.FIVE; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.HUNDRED; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.SIZE_FIFTY; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.SIZE_FIVE; +import static cn.jojo.cc.server.common.utils.ValidatorUtils.SIZE_ONE_HUNDRED; + +/** + * @author lihy + * @version 1.0.0 + * @description: + * @date 2022/6/13 8:24 下午 + */ +@Service +@ApiModule(value = "题库服务", apiInterface = QuestionService.class) +@Slf4j +public class QuestionServiceImpl extends AbstractBaseService implements QuestionService { + + @Resource(name = "newQuestionBizService") + private QuestionBizService questionBizService; + + @Resource + private QuestionCacheService questionCacheService; + + @Resource + private CdnListReplaceUtil cdnListReplaceUtil; + + @Resource + private QuestionUpdateTimeCacheService questionUpdateTimeCacheService; + + @Resource + private QuestionLibraryUpdateTimeCacheService questionLibraryUpdateTimeCacheService; + + @Resource + private QuestionManager questionManager; + + @Resource + private CollectionBusinessCoreService collectionBusinessCoreService; + + @Resource + private CollectionElementCoreService collectionElementCoreService; + + @Resource + private CollectionCacheService collectionCacheService; + + @Resource + private CollectionElementCacheService collectionElementCacheService; + + @Resource + private QuestionCoreService questionCoreService; + + @Resource + private QuestionProduceBizService questionProduceBizService; + + @Resource + private QuestionJsonCoreService questionJsonCoreService; + + @Resource + private GameSkinCacheService gameSkinCacheService; + + @Resource + private QuestionTypeBizService questionTypeBizService; + + @Resource + private InteractiveFormCacheService interactiveFormCacheService; + + @Resource + private FrameResourceService frameResourceService; + + @Resource + private ContentNormConfigCacheService contentNormConfigCacheService; + + @Resource + private FormAndDataCacheService formAndDataCacheService; + + @Resource + private QuestionBaseInfoCacheService questionBaseInfoCacheService; + + /** + * 英文划词装配(工作包 10 Task 23):只读已落库的分词快照,不在本链路做任何分词。 + */ + @Resource + private EnglishSegmentDeliveryBizService englishSegmentDeliveryBizService; + + @Resource + private TemplateLayoutCacheService templateLayoutCacheService; + + @Resource + private QuestionTypeTemplateCacheService questionTypeTemplateCacheService; + + @Resource + private BusinessSchemaCacheService businessSchemaCacheService; + + @Resource + private QuestionReportCacheLoader questionReportCacheLoader; + + @Resource + private PrintableQuestionCacheLoader printableQuestionCacheLoader; + + @Resource + private FormDataCacheService formDataCacheService; + + @Resource + private QuestionContentByIdCacheService questionContentByIdCacheService; + + @Resource + private QuestionKeyIdCacheService questionKeyIdCacheService; + + @Resource + private CombinationQuestionCacheService combinationQuestionCacheService; + + @Resource + private StepComponentCacheService stepComponentCacheService; + + @Resource + private StepComponentBizService stepComponentBizService; + + @Autowired + private TagLibraryService tagLibraryService; + + @Resource + private TenantUtils tenantUtils; + + @Value("#{'${ops.yun.new.json.config:}'.split(',')}") + private List cdnList; + + @Value("${rpc.lesson.some_question_optimize:true}") + private boolean lessonSomeQuestionOptimize; + + @Value("#{${question.group.score.rule:{2:{\"tag_0020509\":100,\"tag_0020510\":1002},3:{\"tag_0020511\":1,\"tag_0020512\":2}}}}") + private Map> questionGroupScoreRuleMap; + + @Resource + private DictService dictService; + + @Override + @ApiDoc(value = "根据业务场景全量获取题ID列表", + description = "根据业务场景全量获取题ID列表,用于教务同步题库数据", + responseClassDescription = "题ID列表") + public IRpcResult> listQuestionIdByScenes(QuestionIdsReq questionIdsReq) { + + Integer businessScenes = questionIdsReq.getBusinessScenes(); + + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + //直接走db查询数据 + return RpcResultUtil.successWithData(getQuestionIdsByScenesFromDb(businessScenes)); + } + + //从缓存中拿题ID列表数据 + Pair> pair = + questionCacheService.getQuestionIdListFromCacheById(businessScenes); + if (pair.getLeft()) { + List questionIds = pair.getRight(); + //判断题ID列表是否是不存在的 + if (CollectionUtils.isEmpty(questionIds)) { + return RpcResultUtil.successWithoutData(String.format("%s,businessScenes=%s", + MessageSourceUtils.getMixedMessage("error_local.question.ids.is_not_exists", + "the question id list does not exist"), businessScenes)); + } + //响应缓存 + return RpcResultUtil.successWithData(questionIds); + } + //缓存里没有数据,走Db查询数据 + List questionIds = getQuestionIdsByScenesFromDb(businessScenes); + //写入redis缓存 + questionCacheService.putQuestionIdListToCache(businessScenes, questionIds); + if (CollectionUtils.isEmpty(questionIds)) { + return RpcResultUtil.successWithoutData(String.format("%s,businessScenes=%s", + MessageSourceUtils.getMixedMessage("error_local.question.ids.is_not_exists", + "the question id list does not exist"), businessScenes)); + } + //响应数据库的数据 + return RpcResultUtil.successWithData(questionIds); + } + + @Override + @ApiDoc(value = "根据题目ID列表获取题配置", + description = "根据题目ID列表获取题配置,用于教务同步题库数据", + responseClassDescription = "题配置") + public IRpcResult> listQuestionByIds(QuestionsReq questionsReq) { + List questionDtoList = questionManager.listQuestionByIds(questionsReq.getQuestionIds()); + if (CollectionUtils.isEmpty(questionDtoList)) { + return RpcResultUtil.successWithoutData( + MessageSourceUtils.getMixedMessage("error_local.question.configs.is_not_exists", + "the question configuration list does not exist")); + } + return RpcResultUtil.successWithData(refactorQuestionDtoList(questionDtoList, true)); + } + + @Override + public IRpcResult> listQuestionByKeys(ListQuestionByKeysReq req) { + Set questionKeys = req.getQuestionKeys(); + boolean isJy = CourseCenterUtil.isJyById(req.getCourseOrLessonId()); + + List questionDtoList = questionManager.listQuestionByKeys(questionKeys, isJy); + if (CollectionUtils.isEmpty(questionDtoList)) { + return RpcResultUtil.successWithoutData( + MessageSourceUtils.getMixedMessage("error_local.question.configs.is_not_exists", + "the question configuration list does not exist")); + } + + return RpcResultUtil.successWithData(refactorQuestionDtoList(questionDtoList, false)); + } + + @Override + public IRpcResult> listSimpleByQuestionKeys(ListQuestionByKeysReq req) { + // 新接口增加租户入参校验 + tenantUtils.getTenantId(); + + Set questionKeys = req.getQuestionKeys(); + boolean isJy = CourseCenterUtil.isJyById(req.getCourseOrLessonId()); + + List questionDtoList = questionManager.listQuestionByKeys(questionKeys, isJy); + if (CollectionUtils.isEmpty(questionDtoList)) { + return RpcResultUtil.successWithoutData( + MessageSourceUtils.getMixedMessage("error_local.question.configs.is_not_exists", + "the question configuration list does not exist")); + } + //查询`question_config` + Map questionInfoMap = getQuestionInfo(questionKeys, !isJy); + //Map + Map templateCacheBoMap = getTemplateCacheBoMap(questionInfoMap, !isJy); + + List resp = new ArrayList<>(questionDtoList.size()); + for (QuestionDto questionDto : questionDtoList) { + //查询弹出形式 + QuestionTypeTemplateCacheBo templateCacheBo = + templateCacheBoMap.getOrDefault(questionDto.getQuestionKey(), + new QuestionTypeTemplateCacheBo()); + QuestionForSimpleDto questionForSimpleDto = QuestionForSimpleDto.builder() + .questionKey(questionDto.getQuestionKey()) + .questionType(questionDto.getQuestionType()) + .popForm(templateCacheBo.getPopForm()) + .build(); + resp.add(questionForSimpleDto); + } + return RpcResultUtil.successWithData(resp); + } + + public Map getTemplateCacheBoMap( + Map questionInfoMap, boolean useCache) { + if (MapUtils.isEmpty(questionInfoMap)) { + return new HashMap<>(); + } + List questionBaseInfoCacheBoList = questionInfoMap.values().stream() + .filter(e -> Objects.equals(e.getQuestionCategory(), + QuestionTypeCategoryEnum.GEN_STANDARD_QUESTION.getOutCategory()) || + Objects.equals(e.getQuestionCategory(), + QuestionTypeCategoryEnum.GEN_INTERACTION_QUESTION.getOutCategory())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(questionBaseInfoCacheBoList)) { + return new HashMap<>(); + } + //查询布局信息 + Map questionKeyLayoutIdMap = questionBaseInfoCacheBoList.stream() + .filter(e -> Objects.nonNull(e) && e.getLayoutId() != null && e.getLayoutId() > Constants.DEFAULT_ID) + .collect(Collectors.toMap( + QuestionBaseInfoCacheBo::getQuestionKey, + QuestionBaseInfoCacheBo::getLayoutId, + (v1, v2) -> v1)); + + if (questionKeyLayoutIdMap.isEmpty()) { + return new HashMap<>(); + } + + Set layoutIds = new HashSet<>(questionKeyLayoutIdMap.values()); + Map layoutMap = templateLayoutCacheService.load(layoutIds, useCache); + if (layoutMap == null || layoutMap.isEmpty()) { + return new HashMap<>(); + } + + // 查询模板信息 + Map layoutIdTemplateIdMap = layoutMap.values().stream() + .filter(Objects::nonNull) + .filter(layout -> layout.getId() != null && layout.getTemplateId() != null) + .collect(Collectors.toMap( + TemplateLayoutCacheBo::getId, + TemplateLayoutCacheBo::getTemplateId)); + + if (layoutIdTemplateIdMap.isEmpty()) { + return new HashMap<>(); + } + + Set templateIds = new HashSet<>(layoutIdTemplateIdMap.values()); + Map templateMap = + questionTypeTemplateCacheService.load(templateIds, useCache); + if (templateMap == null || templateMap.isEmpty()) { + return new HashMap<>(); + } + + // 构建 Map + Map questionKeyTemplateMap = new HashMap<>(); + for (Map.Entry entry : questionKeyLayoutIdMap.entrySet()) { + String questionKey = entry.getKey(); + Long layoutId = entry.getValue(); + if (layoutId == null) { + continue; + } + + TemplateLayoutCacheBo layoutBo = layoutMap.get(layoutId); + if (layoutBo == null || layoutBo.getTemplateId() == null) { + continue; + } + + QuestionTypeTemplateCacheBo templateBo = templateMap.get(layoutBo.getTemplateId()); + if (templateBo == null) { + continue; + } + + questionKeyTemplateMap.put(questionKey, templateBo); + } + return questionKeyTemplateMap; + } + + private List refactorQuestionDtoList(List original, Boolean containQuestionJsonData) { + List result = new ArrayList<>(); + + if (CollectionUtils.isNotEmpty(original)) { + for (QuestionDto questionDto : original) { + QuestionDto copyOfquestionDto = new QuestionDto(); + BeanCopier beanCopier = BeanCopier.create(QuestionDto.class, QuestionDto.class, false); + beanCopier.copy(questionDto, copyOfquestionDto, null); + + // 此字段返回的结构太大,目前已被废弃,直接透传的业务请使用{@link #questionJsonUrl} + // 因为太大,之前被废弃,但是练习场需要用,所以需要用这个开关控制输出 + if (!Boolean.TRUE.equals(containQuestionJsonData)) { + copyOfquestionDto.setQuestionJson(null); + } + + // 根据环境处理JSON_URL + if (CourseCenterUtil.isJyById(copyOfquestionDto.getQuestionId())) { + copyOfquestionDto.setQuestionJsonUrl(copyOfquestionDto.getJyQuestionJsonUrl()); + + // 处理questionDefinition中的ContentUrl + QuestionDefinitionDto questionDefinition = copyOfquestionDto.getQuestionDefinition(); + if (Objects.nonNull(questionDefinition)) { + questionDefinition.setContentUrl(copyOfquestionDto.getQuestionJsonUrl()); + } + } + + result.add(copyOfquestionDto); + } + } + + return result; + } + + @Override + public IRpcResult> listQuestionEnhanceByIds(QuestionsReq questionsReq) { + + List questionIds = questionsReq.getQuestionIds().stream().distinct().collect(Collectors.toList()); + + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + //直接走db查询数据 + List questionDtoListDb = getQuestionEnhanceDtoListByIdsFromDb(questionIds); + if (CollectionUtils.isEmpty(questionDtoListDb)) { + return RpcResultUtil.successWithoutData(MessageSourceUtils.getMixedMessage("error_local.question.configs.is_not_exists","the question configuration list does not exist")); + } + // 替换cdnList + for (QuestionEnhanceDto questionEnhanceDto : questionDtoListDb) { + if (questionEnhanceDto != null) { + questionEnhanceDto.setCdnList( + cdnListReplaceUtil.replaceCdnListDomain(questionEnhanceDto.getCdnList(), CdnListReplaceUtil.OPS_YUN_NEW_JSON_CONFIG)); + } + } + return RpcResultUtil.successWithData(questionDtoListDb); + } + + Set uniqueQuestionIds = Sets.newHashSet(questionIds); + + // 查询缓存 + Map questionEnhanceDtoMap = + questionCacheService.getQuestionEnhanceDtoFromCacheById(uniqueQuestionIds); + + // 本地缓存拿到的是一个不可变 map + questionEnhanceDtoMap = + Objects.nonNull(questionEnhanceDtoMap) ? Maps.newHashMap(questionEnhanceDtoMap) : Maps.newHashMap(); + + Sets.SetView noCachedQuestionIds = Sets.difference(uniqueQuestionIds, questionEnhanceDtoMap.keySet()); + if (CollectionUtils.isNotEmpty(noCachedQuestionIds)) { + List dbQuestionDtoList = + getQuestionEnhanceDtoListByIdsFromDb(Lists.newArrayList(noCachedQuestionIds)); + Map dbQuestionEnhanceDtoMap = + Optional.ofNullable(dbQuestionDtoList).orElse(Collections.emptyList()).stream() + .collect(Collectors.toMap(QuestionEnhanceDto::getQuestionId, Function.identity())); + + Map cacheMap = + noCachedQuestionIds.stream().collect(Collectors.toMap(Function.identity(), + questionId -> dbQuestionEnhanceDtoMap.getOrDefault(questionId, + QuestionCacheService.DEFAULT_ENHANCE), (v1, v2) -> v1)); + questionCacheService.putQuestionEnhanceDtoToCache(cacheMap); + + if (MapUtils.isNotEmpty(dbQuestionEnhanceDtoMap)) { + questionEnhanceDtoMap.putAll(dbQuestionEnhanceDtoMap); + } + } + + List questionEnhanceDtoList = questionEnhanceDtoMap.entrySet().stream() + .filter(entry -> entry.getKey().equals(entry.getValue().getQuestionId())) + .map(entry -> { + QuestionEnhanceDto questionEnhanceDto = entry.getValue(); + questionEnhanceDto.setCdnList( + cdnListReplaceUtil.replaceCdnListDomain(questionEnhanceDto.getCdnList(), + CdnListReplaceUtil.OPS_YUN_NEW_JSON_CONFIG)); + return questionEnhanceDto; + }).collect(Collectors.toList()); + + if (CollectionUtils.isEmpty(questionEnhanceDtoList)) { + return RpcResultUtil.successWithoutData( + MessageSourceUtils.getMixedMessage("error_local.question.configs.is_not_exists", + "the question configuration list does not exist")); + } + + return RpcResultUtil.successWithData(questionEnhanceDtoList); + } + + @Override + public IRpcResult> listKnowledgeLabelByQuestionKeys(KnowledgeLabelReq knowledgeLabelReq) { + Set questionKeys = knowledgeLabelReq.getQuestionKeys(); + List dtoList; + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + // 直接走db查询数据 + dtoList = listKnowledgeLabelByQuestionKeysFromDb(Lists.newArrayList(questionKeys)); + } else { + // 查询缓存 + dtoList = listKnowledgeLabelByQuestionKeysFromCache(Lists.newArrayList(questionKeys)); + } + if (CollectionUtils.isEmpty(dtoList)) { + return RpcResultUtil.successWithoutData( + MessageSourceUtils.getMixedMessage("error_local.knowledge_label.configs.is_not_exists", + "the knowledge label list does not exist") + ); + } + return RpcResultUtil.successWithData(dtoList); + } + + private List listKnowledgeLabelByQuestionKeysFromCache(List questionKeys) { + Map resultCacheMap = + questionCacheService.listKnowledgeLabelByQuestionKeysFromCache(Lists.newArrayList(questionKeys)); + + Set cacheKeys = resultCacheMap.keySet(); + List cacheResults = resultCacheMap.values().stream() + .filter(knowledgeLabelDto -> knowledgeLabelDto.getQuestionId() != Constants.DEFAULT_CACHE_OBJECT_ID) + .collect(Collectors.toList()); + + // 缓存全部命中 + if (cacheKeys.size() == questionKeys.size()) { + return cacheResults; + } + + // 缓存部分命中,去除已命中的集合,未命中的继续走db查询 + questionKeys.removeAll(cacheKeys); + + // 走db查询 + List dbResults = listKnowledgeLabelByQuestionKeysFromDb(Lists.newArrayList(questionKeys)); + + // 写入缓存 + Map dbDtoMap = dbResults.stream() + .collect(Collectors.toMap(KnowledgeLabelDto::getQuestionKey, Function.identity(), (v1, v2) -> v1)); + questionCacheService.putKnowledgeLabelToCache(dbDtoMap, Lists.newArrayList(questionKeys)); + + // 合并缓存和db的结果集 + return ListUtils.union(cacheResults, dbResults); + } + + private List listKnowledgeLabelByQuestionKeysFromDb(List questionKeys) { + List knowledgeLabelBos = questionBizService.listKnowledgeLabelByQuestionKeys(questionKeys); + return KnowledgeLabelBo2DtoConvert.INSTANCE.to(knowledgeLabelBos); + } + + private List getQuestionEnhanceDtoListByIdsFromDb(List questionIds) { + List questionEnhanceRemoteResponseBos = + questionBizService.listQuestionEnhanceByIdsToRemote(questionIds); + List dtoList = QuestionEnhanceBo2DtoConvert.INSTANCE.to(questionEnhanceRemoteResponseBos); + dtoList.forEach(questionEnhanceDto -> questionEnhanceDto.setCdnList(cdnList)); + return dtoList; + } + + private List getQuestionIdsByScenesFromDb(Integer businessScenes) { + return questionBizService.listIdByBusinessScenes(businessScenes); + } + + @Override + public IRpcResult getQuestionUpdateTimeByKey(QuestionUpdateTimeReq req) { + String questionKey = req.getQuestionKey(); + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + QuestionUpdateTimeBo updateTimeBo = null; + // 2025.02.22 兼容环节中可以配置题库题, 弃用 businessType 字段判断是否是题库题的逻辑 + // 题库题 + if(questionKey.startsWith("Q_")) { + updateTimeBo = questionLibraryUpdateTimeCacheService.load(req.getQuestionKey(), useCache); + } else{ + updateTimeBo = questionUpdateTimeCacheService.load(req.getQuestionKey(), useCache); + } + QuestionUpdateTimeDto updateTimeDto = null; + if (updateTimeBo != null) { + updateTimeDto = new QuestionUpdateTimeDto(); + updateTimeDto.setQuestionKey(updateTimeBo.getQuestionKey()); + updateTimeDto.setUpdateTime(updateTimeBo.getUpdateTime()); + } + + return RpcResultUtil.successWithData(updateTimeDto); + } + + @Override + public IRpcResult> existLessonSameQuestions(ListLessonSameQuestionsReq req) { + if (lessonSomeQuestionOptimize) { + return existLessonSameQuestionsV2(req); + } + + List lessonIds = req.getLessonIds(); + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + // 包装结果 + List resultDto = lessonIds.stream().map(e -> { + ExistLessonSameQuestionsDto result = new ExistLessonSameQuestionsDto(); + result.setLessonId(e); + result.setExistSameQuestions(false); + return result; + }).collect(Collectors.toList()); + + // 查询课时关联的集合ID + List collectionBusinessDos = useCache ? + listCollectionBusinessIdAndTypeCache(lessonIds,BusinessTypeEnum.LESSON) : listCollectionBusinessByLessonIdsDb(lessonIds); + if (CollectionUtils.isEmpty(collectionBusinessDos)) { + return RpcResultUtil.successWithData(resultDto); + } + + // 通过集合ID查询关联的题ID + Map> lessonIdMap = collectionBusinessDos.stream() + .collect(Collectors.groupingBy(CollectionBusinessDo::getBusinessId, + Collectors.mapping(CollectionBusinessDo::getCollectionId, Collectors.toList()))); + List collectionIds = lessonIdMap.values().stream() + .flatMap(Collection::stream) + .collect(Collectors.toList()); + // 过滤条件: + // -- 必须存在母题 + // SUM(cce.element_role = 1) > 0 + // AND + // -- 必须存在非母题 + // SUM(cce.element_role > 1) > 0 + Map> collectionElementMap = (useCache ? + listCollectionElementByCollectionIdsCache(collectionIds) : + listCollectionElementByCollectionIdsDb(collectionIds)) + .stream().collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)) + .entrySet().stream() + .filter(entry -> entry.getValue().stream().anyMatch(e -> e.getElementRole() == 1) + && entry.getValue().stream().anyMatch(e -> e.getElementRole() > 1)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Set elementIds = collectionElementMap.values().stream() + .flatMap(Collection::stream) + .map(CollectionElementDo::getElementId) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(elementIds)) { + return RpcResultUtil.successWithData(resultDto); + } + + // 查询题是否存在,题可能会很多,分批查询 + int batchSize = 500; + Map resultMap = new HashMap<>(); + List queryElementIds = new ArrayList<>(elementIds); + for (int i = 0; i < queryElementIds.size(); i += batchSize) { + List batch = queryElementIds.subList(i, Math.min(i + batchSize, queryElementIds.size())); + Map questionExistMap = questionKeyIdCacheService.load(new HashSet<>(batch), useCache); + batch.forEach(id -> resultMap.put(id, questionExistMap.containsKey(id))); + } + + // 结果查询 + for (ExistLessonSameQuestionsDto existLessonSameQuestionsDto : resultDto) { + Long lessonId = existLessonSameQuestionsDto.getLessonId(); + List queryCollectionIds = lessonIdMap.get(lessonId); + if (CollectionUtils.isEmpty(queryCollectionIds)) { + continue; + } + for (Long queryCollectionId : queryCollectionIds) { + List collectionElementDos = collectionElementMap.get(queryCollectionId); + if (CollectionUtils.isEmpty(collectionElementDos)) { + continue; + } + // 过滤条件,有效题组+题存在: + // SUM(cce.element_role = 1) > 0 + // SUM(cce.element_role > 1) > 0 + boolean hasMotherElement = false; + boolean hasChildElement = false; + for (CollectionElementDo collectionElementDo : collectionElementDos) { + Boolean hasQuestion = resultMap.getOrDefault(collectionElementDo.getElementId(), false); + if (collectionElementDo.getElementRole() == 1 && hasQuestion) { + hasMotherElement = true; + } else if (collectionElementDo.getElementRole() > 1 && hasQuestion) { + hasChildElement = true; + } + if (hasMotherElement && hasChildElement) { + existLessonSameQuestionsDto.setExistSameQuestions(true); + break; + } + } + if (existLessonSameQuestionsDto.getExistSameQuestions()) { + break; + } + } + } + + return RpcResultUtil.successWithData(resultDto); + } + + private IRpcResult> existLessonSameQuestionsV2(ListLessonSameQuestionsReq req){ + List lessonIds = req.getLessonIds(); + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + + List resultDto = new ArrayList<>(); + if (useCache) { + resultDto = questionCacheService.existLessonSameQuestionByCache(lessonIds); + + List cacheLessonIds = + resultDto.stream().map(ExistLessonSameQuestionsDto::getLessonId) + .collect(Collectors.toList()); + lessonIds = lessonIds.stream().filter(i -> !cacheLessonIds.contains(i)).collect(Collectors.toList()); + } + + // 查询课时关联的集合ID + List collectionBusinessDos = listCollectionBusinessByLessonIdsDb(lessonIds); + + // 通过集合ID查询关联的题ID + Map> lessonIdMap = collectionBusinessDos.stream() + .collect(Collectors.groupingBy(CollectionBusinessDo::getBusinessId, + Collectors.mapping(CollectionBusinessDo::getCollectionId, Collectors.toList()))); + List collectionIds = lessonIdMap.values().stream() + .flatMap(Collection::stream) + .collect(Collectors.toList()); + // 过滤条件: + // -- 必须存在母题 + // SUM(cce.element_role = 1) > 0 + // AND + // -- 必须存在非母题 + // SUM(cce.element_role > 1) > 0 + Map> collectionElementMap = listCollectionElementByCollectionIdsDb(collectionIds) + .stream().collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)) + .entrySet().stream() + .filter(entry -> entry.getValue().stream().anyMatch(e -> e.getElementRole() == 1) + && entry.getValue().stream().anyMatch(e -> e.getElementRole() > 1)) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + + Set elementIds = collectionElementMap.values().stream() + .flatMap(Collection::stream) + .map(CollectionElementDo::getElementId) + .collect(Collectors.toSet()); + + // 查询题是否存在,题可能会很多,分批查询 + int batchSize = 500; + Map resultMap = new HashMap<>(); + List queryElementIds = new ArrayList<>(elementIds); + for (int i = 0; i < queryElementIds.size(); i += batchSize) { + List batch = queryElementIds.subList(i, Math.min(i + batchSize, queryElementIds.size())); + Map questionExistMap = questionKeyIdCacheService.load(new HashSet<>(batch), false); + batch.forEach(id -> resultMap.put(id, questionExistMap.containsKey(id))); + } + + Map cacheMap = new HashMap<>(); + // 结果查询 + for (Long lessonId: lessonIds) { + List queryCollectionIds = lessonIdMap.getOrDefault(lessonId,new ArrayList<>()); + + ExistLessonSameQuestionsDto existLessonSameQuestionsDto = new ExistLessonSameQuestionsDto(); + existLessonSameQuestionsDto.setLessonId(lessonId); + existLessonSameQuestionsDto.setExistSameQuestions(false); + + for (Long queryCollectionId : queryCollectionIds) { + List collectionElementDos = collectionElementMap.get(queryCollectionId); + if (CollectionUtils.isEmpty(collectionElementDos)) { + continue; + } + // 过滤条件,有效题组+题存在: + // SUM(cce.element_role = 1) > 0 + // SUM(cce.element_role > 1) > 0 + boolean hasMotherElement = false; + boolean hasChildElement = false; + for (CollectionElementDo collectionElementDo : collectionElementDos) { + Boolean hasQuestion = resultMap.getOrDefault(collectionElementDo.getElementId(), false); + if (collectionElementDo.getElementRole() == 1 && hasQuestion) { + hasMotherElement = true; + } else if (collectionElementDo.getElementRole() > 1 && hasQuestion) { + hasChildElement = true; + } + if (hasMotherElement && hasChildElement) { + existLessonSameQuestionsDto.setExistSameQuestions(true); + break; + } + } + if (existLessonSameQuestionsDto.getExistSameQuestions()) { + break; + } + } + + if (existLessonSameQuestionsDto.getExistSameQuestions()) { + cacheMap.put(questionCacheService.getLessonExistSameQuestionKey(lessonId), 1 + ""); +// questionCacheService.setLessonSameQuestion(lessonId, 1); + } else { + cacheMap.put(questionCacheService.getLessonExistSameQuestionKey(lessonId), 0 + ""); +// questionCacheService.setLessonSameQuestion(lessonId, 0); + } + + resultDto.add(existLessonSameQuestionsDto); + } + + if (!cacheMap.isEmpty()) { + questionCacheService.existLessonSameQuestionBatchSet(cacheMap); + } + + return RpcResultUtil.successWithData(resultDto); + } + + @Override + public IRpcResult> listLessonSameQuestions(ListLessonSameQuestionsReq req) { + List lessonIds = req.getLessonIds(); + Boolean includeDeprecated = req.getIncludeDeprecated(); + List response; + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + response = listLessonSameQuestionsFromDb(lessonIds); + } else { + response = listLessonSameQuestionsFromCache(lessonIds); + } + + // 母题未发布时,sameQuestions返回值不能包含这个题组 + for (LessonSameQuestionsDto lessonSameQuestionsDto : response) { + List sameQuestions = lessonSameQuestionsDto.getSameQuestions(); + if (CollectionUtils.isNotEmpty(sameQuestions)) { + sameQuestions.removeIf(next -> Objects.isNull(next.getMainQuestion())); + } + } + + return DefaultRpcResult.successWithData(response); + } + + @Override + public IRpcResult> listTestSameQuestions(ListTestSameQuestionsReq req) { + List testsIds = req.getTestIds(); + Boolean includeDeprecated = req.getIncludeDeprecated(); + List response; + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + response = listSameQuestionsFromDb(testsIds, BusinessTypeEnum.TEST.getType()); + } else { + response = listSameQuestionsFromCache(testsIds, BusinessTypeEnum.TEST.getType()); + } + + // 母题未发布时,sameQuestions返回值不能包含这个题组 + for (BusinessSameQuestionsDto businessSameQuestionsDto : response) { + List sameQuestions = businessSameQuestionsDto.getSameQuestions(); + if (CollectionUtils.isNotEmpty(sameQuestions)) { + sameQuestions.removeIf(next -> Objects.isNull(next.getMainQuestion())); + } + } + + return DefaultRpcResult.successWithData(response); + } + + @Override + public IRpcResult listSameQuestionsByLessonId(ListSameQuestionsReq req) { + Long lessonId = req.getLessonId(); + Boolean includeDeprecated = req.getIncludeDeprecated(); + LessonSameQuestionsDto result = new LessonSameQuestionsDto(); + result.setLessonId(lessonId); + result.setSameQuestions(new ArrayList<>()); + + // 查询课时关联的集合ID + List businesses = collectionBusinessCoreService.listValidByBusinessIds( + Lists.newArrayList(lessonId), + BusinessTypeEnum.LESSON); + if (CollectionUtils.isEmpty(businesses)) { + return DefaultRpcResult.successWithData(result); + } + // 查询集合ID关联的母题ID + Set collectionIds = businesses.stream() + .map(CollectionBusinessDo::getCollectionId) + .collect(Collectors.toSet()); + List mainElements = collectionElementCoreService.listByCollectionIds( + collectionIds, + ElementTypeEnum.QUESTION_LIB, + ElementRoleEnum.MAIN_QUESTION + ); + if (CollectionUtils.isEmpty(mainElements)) { + return DefaultRpcResult.successWithData(result); + } + List mainQuestionIds = mainElements.stream() + .map(CollectionElementDo::getElementId) + .collect(Collectors.toList()); + + // 查询母题/拆步题详情 + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(mainQuestionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.ALL.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + + // 排序,母题的顺序为母题所在组的CollectionBusinessDo中的顺序 + Map elementDoMap = mainElements.stream().collect( + Collectors.toMap(CollectionElementDo::getElementId, UnaryOperator.identity(), (a, b) -> b)); + Map collectionSortOrderMap = businesses.stream().collect( + Collectors.toMap(CollectionBusinessDo::getCollectionId, CollectionBusinessDo::getSortOrder, (a, b) -> b)); + + questionSetDtoList.sort(Comparator.comparingInt(e -> { + Long collectionId = elementDoMap.get(e.getQuestionId()).getCollectionId(); + // 数据库中默认值为1 + return collectionSortOrderMap.getOrDefault(collectionId, 1); + })); + + // 构建返回DTO对象 + buildSameQuestionDto(result.getSameQuestions(), questionSetDtoList, lessonId, elementDoMap, collectionSortOrderMap); + + // 构建题生产状态 + buildRequireState(result.getSameQuestions()); + + return DefaultRpcResult.successWithData(result); + } + + @Override + public IRpcResult> listSubQuestions(ListSubQuestionsReq req) { + String questionKey = req.getQuestionKey(); + Boolean includeDeprecated = req.getIncludeDeprecated(); + List result = new ArrayList<>(); + QuestionDo questionDo = questionCoreService.getByKey(questionKey); + if (questionDo == null) { + return DefaultRpcResult.successWithData(result); + } + Long questionId = questionDo.getId(); + // 根据题ID查询题所在的集合ID + CollectionElementDo collectionElementDo = getMainElementByQuestionId(questionId); + if (collectionElementDo == null) { + return DefaultRpcResult.successWithData(result); + } + // 通过集合ID查询集合元素 + Long collectionId = collectionElementDo.getCollectionId(); + List subElements = collectionElementCoreService.listByCollectionIds( + Collections.singletonList(collectionId), + ElementTypeEnum.QUESTION_LIB, + ElementRoleEnum.MOCK_SUB_QUESTION, + ElementRoleEnum.VARIANT_SUB_QUESTION, + ElementRoleEnum.COMPREHENSIVE_SUB_QUESTION + ); + if (CollectionUtils.isEmpty(subElements)) { + return DefaultRpcResult.successWithData(result); + } + + // 查询母题/拆步题详情 + Map elementDoMap = subElements.stream() + .collect(Collectors.toMap(CollectionElementDo::getElementId, UnaryOperator.identity(), (v1, v2) -> v2)); + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(elementDoMap.keySet()) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + + // 构建角色+子题排序+重置sortOrder + // elementRole -> sortOrder + if (CollectionUtils.isNotEmpty(questionSetDtoList)) { + Map questionSetDtoMap = questionSetDtoList.stream() + .collect(Collectors.toMap(QuestionSetDto::getQuestionId, UnaryOperator.identity(), (v1, v2) -> v1)); + for (CollectionElementDo subElement : subElements) { + Long elementId = subElement.getElementId(); + if (questionSetDtoMap.containsKey(elementId)) { + QuestionSetDto questionSetDto = questionSetDtoMap.get(elementId); + questionSetDto.setElementRole(subElement.getElementRole()); + result.add(questionSetDto); + } + } + + // 构建题生产状态 + buildSubRequireState(result); + } + + return DefaultRpcResult.successWithData(result); + } + + @Override + public IRpcResult> listMetaQuestionsByKeys(ListMetaQuestionsByKeysReq req) { + Set questionKeys = req.getQuestionKeys(); + Boolean includeDeprecated = req.getIncludeDeprecated(); + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + + // 获取母题id + Map questionInfoMap = getQuestionInfo(questionKeys, redisEnabled); + List questionBaseInfoCacheBoList = questionInfoMap.values() + .stream() + .filter(e -> StringUtils.isNotBlank(e.getQuestionKey())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(questionBaseInfoCacheBoList)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + Set mainQuestionIds = questionBaseInfoCacheBoList.stream() + .map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(mainQuestionIds)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + + // 获取母题信息 + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(Sets.newHashSet(mainQuestionIds)) + .queryBusinessType(QuestionQueryBusinessTypeEnum.STEP_QUESTION_GROUP.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .expectQuestionGroup(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + List metaQuestionSetDtoList = questionSetDtoList.stream().map(questionSetDto -> { + MetaQuestionSetDto metaQuestionSetDto = new MetaQuestionSetDto(); + metaQuestionSetDto.setMainQuestion(questionSetDto); + return metaQuestionSetDto; + }).collect(Collectors.toList()); + + // 获取集合ID + Map mainElements = collectionElementCacheService.load(mainQuestionIds, redisEnabled); + List collectionIds = + mainElements.values().stream().map(CollectionElementDo::getCollectionId).collect(Collectors.toList()); + + // 获取子题 + List subElements = listSubElements(collectionIds); + + if (CollectionUtils.isNotEmpty(subElements)) { + Set questionIds = new HashSet<>(); + // key:collectionId => value:collectionElementDos + Map> collectionElementsMap = new HashMap<>(); + for (CollectionElementDo element : subElements) { + Long collectionId = element.getCollectionId(); + List elementDoList; + if (collectionElementsMap.containsKey(collectionId)) { + elementDoList = collectionElementsMap.get(collectionId); + } else { + elementDoList = new ArrayList<>(); + collectionElementsMap.put(collectionId, elementDoList); + } + elementDoList.add(element); + questionIds.add(element.getElementId()); + } + + query = SearchQuestionBo.builder() + .ids(questionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + // questionId => QuestionSetDto + Map questionSetDtoMap = Optional.ofNullable(questionSetDtoList) + .orElse(new ArrayList<>(0)) + .stream() + .collect( + Collectors.toMap(QuestionSetDto::getQuestionId, Function.identity(), (v1, v2) -> v1) + ); + mainElements.forEach((mainElementId, mainElement) -> { + for (MetaQuestionSetDto metaQuestionSetDto : metaQuestionSetDtoList) { + List subQuestions = new ArrayList<>(); + QuestionSetDto mainQuestion = metaQuestionSetDto.getMainQuestion(); + if (mainElementId.equals(mainQuestion.getQuestionId())) { + Long collectionId = mainElement.getCollectionId(); + mainQuestion.setElementRole(mainElement.getElementRole()); + metaQuestionSetDto.setMainQuestion(mainQuestion); + List elementDoList = + collectionElementsMap.getOrDefault(collectionId, new ArrayList<>()); + for (CollectionElementDo subElement : elementDoList) { + Long questionId = subElement.getElementId(); + // 有可能题没有发布 + if (questionSetDtoMap.containsKey(questionId)) { + QuestionSetDto questionSet = questionSetDtoMap.get(questionId); + if (!ElementRoleEnum.MAIN_QUESTION.getCode() + .equals(subElement.getElementRole())) { + questionSet.setElementRole(subElement.getElementRole()); + subQuestions.add(questionSet); + } + } + } + metaQuestionSetDto.setSubQuestions(subQuestions); + } + } + }); + } + + return DefaultRpcResult.successWithData(metaQuestionSetDtoList); + } + + private List listSubElements(List collectionIds) { + if (!ApiServerConfig.INSTANCE.isRedisEnabled()) { + return listSubElementsFromDb(collectionIds); + } else { + return listSubElementsFromCache(collectionIds); + } + } + + private List listSubElementsFromCache(List collectionIds) { + List subElements = new ArrayList<>(); + Map> cacheElementsMap = + collectionCacheService.listElementsFromCache(collectionIds); + Set noneCacheCollectionIds = new HashSet<>(); + for (Long collectionId : collectionIds) { + Set cacheElements = cacheElementsMap.get(collectionId); + if (Objects.isNull(cacheElements)) { + noneCacheCollectionIds.add(collectionId); + } else if (!cacheElements.isEmpty()){ + subElements.addAll(cacheElements.stream() + .filter(element -> !ElementRoleEnum.MAIN_QUESTION.getCode().equals(element.getElementRole())) + .collect(Collectors.toList())); + } + } + // 缓存collectionElement + if (!noneCacheCollectionIds.isEmpty()) { + List elementsFromDb = collectionElementCoreService.listByCollectionIds( + noneCacheCollectionIds, + ElementTypeEnum.QUESTION_LIB + ); + Map> noneCacheElementMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(elementsFromDb)) { + subElements.addAll(elementsFromDb); + noneCacheElementMap = elementsFromDb + .stream() + .collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)); + } + for (Long noneCacheCollectionId : noneCacheCollectionIds) { + if (!noneCacheElementMap.containsKey(noneCacheCollectionId)) { + noneCacheElementMap.put(noneCacheCollectionId, new ArrayList<>(0)); + } + } + if (!noneCacheElementMap.isEmpty()) { + collectionCacheService.putElementToCache(noneCacheElementMap); + } + } + return subElements; + } + + private List listSubElementsFromDb(List collectionIds) { + List collectionElementDos = collectionElementCoreService.listByCollectionIds( + collectionIds, + ElementTypeEnum.QUESTION_LIB + ); + return collectionElementDos.stream() + .filter(element -> !ElementRoleEnum.MAIN_QUESTION.getCode().equals(element.getElementRole())) + .collect(Collectors.toList()); + } + + @Override + public IRpcResult getQuestionJsonByKey(GetQuestionJsonReq req) { + String questionKey = req.getQuestionKey(); + QuestionJsonUrlDto result = new QuestionJsonUrlDto(); + + QuestionJsonDo questionJsonDo = questionJsonCoreService.getByQuestionKey(questionKey); + QuestionDo questionDo = questionCoreService.getByKey(questionKey); + if (questionDo == null) { + return DefaultRpcResult.successWithData(result); + } + if (questionJsonDo != null) { + result.setQuestionJsonUrl(UriToolsUtil.getPath(questionJsonDo.getJsonUrl())); + } + + return DefaultRpcResult.successWithData(result); + } + + @Override + public IRpcResult> listStandardQuestion(ListStandardQuestionReq req) { + if (CollectionUtils.isEmpty(req.getKeys())) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + //查询益智标准题题内容 + Set yzKeys = req.getKeys() + .stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getQuestionKey()) && StringUtils.isNotBlank(e.getGameSkinKey())) + .collect(Collectors.toSet()); + List resultList = new ArrayList<>(); + if (CollectionUtils.isNotEmpty(yzKeys)) { + List yzStandQuestionList = listYZStandQuestion(yzKeys); + if (CollectionUtils.isNotEmpty(yzStandQuestionList)) { + resultList.addAll(yzStandQuestionList); + } + } + //查询通用标准题题内容 + Set genKeys = req.getKeys() + .stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getQuestionKey()) && StringUtils.isBlank(e.getGameSkinKey())) + .map(StandardQuestionUniqueKeyReq::getQuestionKey) + .collect(Collectors.toSet()); + if (CollectionUtils.isNotEmpty(genKeys)) { + Map selectedOutputs = new HashMap<>(); + List genStandQuestionList = listGenStandQuestion(genKeys, selectedOutputs); + if (CollectionUtils.isNotEmpty(genStandQuestionList)) { + buildGenStandQuestionScore(genStandQuestionList, req); + //查询组合题的小题 + buildSmallQuestions(genStandQuestionList, req); + //装配英文划词:小题已在上面递归里走过同一个入口,父题与小题的 key 不重叠,不会重复查库 + fillSegments(genStandQuestionList, selectedOutputs); + resultList.addAll(genStandQuestionList); + } + } + return DefaultRpcResult.successWithData(resultList); + } + + private void buildGenStandQuestionScore(List genStandQuestionList, + ListStandardQuestionReq req) { + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + if (CollectionUtils.isEmpty(genStandQuestionList) || MapUtils.isEmpty(questionGroupScoreRuleMap)) { + return; + } + //Map + Map needScoreMap = req.getKeys().stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getComponentKey())) + .collect(Collectors.toMap(StandardQuestionUniqueKeyReq::getQuestionKey, + StandardQuestionUniqueKeyReq::getComponentKey, (v1, v2) -> v1)); + if (MapUtils.isEmpty(needScoreMap)) { + return; + } + Map stepComponentKeyBoMap = + getStepComponentKeyBoMap(Sets.newHashSet(needScoreMap.values())); + if (MapUtils.isEmpty(stepComponentKeyBoMap)) { + return; + } + // 查询表单数据 + List stepComponentIds = + stepComponentKeyBoMap.values().stream().map(StepComponentBo::getId).collect(Collectors.toList()); + List formDataList = formDataCacheService.loadList( + formDataCacheService.buildCacheKey( + stepComponentIds, + FormDataBusinessTypeEnum.STEP_COMPONENT, + BusinessTypeClassifyEnum.STEP_COMPONENT + ), + redisEnabled + ).stream().filter(e -> Objects.nonNull(e.getFormData())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(formDataList)) { + return; + } + // 查询数据输出schema数据 + List componentIds = + stepComponentKeyBoMap.values().stream().map(StepComponentBo::getComponentId).collect(Collectors.toList()); + List schemaCacheBoList = businessSchemaCacheService.loadList( + businessSchemaCacheService.buildCacheKey(componentIds, BusinessSchemaRelTypeEnum.STEP_COMPONENT), + redisEnabled + ).stream().filter(e -> Objects.nonNull(e.getReportSchema())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(schemaCacheBoList)) { + return; + } + + Map formDataMap = formDataList.stream() + .collect(Collectors.toMap(FormDataCacheBo::getBusinessId, Function.identity(), (v1, v2) -> v1)); + Map schemaMap = schemaCacheBoList.stream().collect( + Collectors.toMap(BusinessSchemaCacheBo::getBusinessId, Function.identity() + , (k1, k2) -> k2) + ); + + //Map> + Map> componentKeyQuestionKeyMap = new HashMap<>(); + //Map + Map componentKeyScoreRulesMap = stepComponentKeyBoMap.entrySet().stream() + .filter(e -> + formDataMap.containsKey(e.getValue().getId()) && schemaMap.containsKey(e.getValue().getComponentId()) + ) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> { + StepComponentBo stepComponentBo = entry.getValue(); + FormDataCacheBo formDataCacheBo = formDataMap.get(stepComponentBo.getId()); + BusinessSchemaCacheBo schemaCacheBo = schemaMap.get(stepComponentBo.getComponentId()); + + try { + JSON outJSON = JsonSchemaExtractDataUtil.buildGameSchemaData( + formDataCacheBo.getFormData().toJSONString(), + JsonSchemaUtil.strToJsonNode(schemaCacheBo.getReportSchema()), + true + ); + QuestionGroupDto questionGroupDto = + Objects.isNull(outJSON) ? null : JSON.toJavaObject(outJSON, QuestionGroupDto.class); + //记录模块和题关系,用于校验入参是否正确 + + List questionDtos = + Optional.ofNullable(questionGroupDto).map(QuestionGroupDto::getQuestions) + .orElse(new ArrayList<>()); + Set questionKeys = questionDtos.stream().map(QuestionGroupDto.QuestionDto::getQuestionKey) + .filter(StringUtils::isNotBlank).collect(Collectors.toSet()); + componentKeyQuestionKeyMap.put(entry.getKey(), questionKeys); + return Optional.ofNullable(questionGroupDto) + .map(QuestionGroupDto::getQuestionScoreSetting) + .map(QuestionGroupDto.QuestionScoreSettingDto::getScoreRules) + .orElse(Constants.ZERO_NUM); + } catch (Exception e) { + log.error("解析JSON失败", e); + return Constants.ZERO_NUM; + } + })); + if (MapUtils.isEmpty(componentKeyScoreRulesMap)) { + log.error("获取标准题分值失败,未查询到模块KEY对应的fromData或schema!"); + return; + } + //Map + Map questionKeyScoreRulesMap = needScoreMap.entrySet().stream().filter( + entry -> + //模块KEY存在规则 且不是 `无规则` + componentKeyScoreRulesMap.containsKey(entry.getValue()) && + !Objects.equals(Constants.ONE, componentKeyScoreRulesMap.get(entry.getValue())) && + //模块KEY和题key对应关系存在 + componentKeyQuestionKeyMap.containsKey(entry.getValue()) && + Objects.nonNull(componentKeyQuestionKeyMap.get(entry.getValue())) && + componentKeyQuestionKeyMap.get(entry.getValue()).contains(entry.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> componentKeyScoreRulesMap.get(entry.getValue()))); + //查询题标签 + boolean isProd; + if (Objects.nonNull(req.getCourseId())) { + isProd = CourseCenterUtil.isProdById(req.getCourseId()); + } else { + isProd = DataBaseTypeEnum.PROD.getValue().equals(req.getEnv()); + } + + QuestionLibraryTagQueryReq tagReq = QuestionLibraryTagQueryReq.builder() + .isProd(isProd) + .questionKeys(questionKeyScoreRulesMap.keySet()).build(); + IRpcResult>> rpcResult = tagLibraryService.queryQuestionLibraryTag(tagReq); + //Map + Map> questionKeyTagMap = + rpcResult.checkSuccess() && MapUtils.isNotEmpty(rpcResult.getData()) ? + rpcResult.getData() : Collections.emptyMap(); + if (MapUtils.isEmpty(questionKeyTagMap)) { + return; + } + + //Map + Map questionKeyScoreMap = questionKeyScoreRulesMap.entrySet().stream().filter(e -> + questionKeyTagMap.containsKey(e.getKey()) && questionGroupScoreRuleMap.containsKey(e.getValue())) + .collect(Collectors.toMap(Map.Entry::getKey, entry -> { + //Map + Map tagScoreRuleMap = questionGroupScoreRuleMap.get(entry.getValue()); + List tags = questionKeyTagMap.get(entry.getKey()); + return tags.stream().map(TagDto::getKey) + .filter(tagScoreRuleMap::containsKey) + .findFirst() + .map(tagScoreRuleMap::get) + .orElse(Constants.ZERO_NUM); + })); + + genStandQuestionList.forEach(question -> + question.setScore(questionKeyScoreMap.getOrDefault(question.getQuestionKey(), Constants.ZERO_NUM))); + } + + public Map getStepComponentKeyBoMap(Set componentKeys) { + if (CollectionUtils.isEmpty(componentKeys)) { + return new HashMap<>(); + } + + //查询缓存 + if (ApiServerConfig.INSTANCE.isRedisEnabled()) { + Map cachedMap = stepComponentCacheService.getStepComponentKeyMap(componentKeys); + //对比出增量未命中缓存KEY + Sets.SetView missedKeys = Sets.difference(componentKeys, cachedMap.keySet()); + if (CollectionUtils.isNotEmpty(missedKeys)) { + //增量KEY查询数据库 + Map dbMap = stepComponentBizService.getStepComponentKeyMap(missedKeys); + //增量写入缓存 + stepComponentCacheService.putStepComponentKeyCache(dbMap); + + //合并结果 + HashMap allMap = new HashMap<>(cachedMap); + allMap.putAll(dbMap); + return allMap; + } + return cachedMap; + } + //查询数据库 + return stepComponentBizService.getStepComponentKeyMap(componentKeys); + } + + private void buildSmallQuestions(List genStandQuestionList, ListStandardQuestionReq req) { + //过滤出组合题 + Set combinationQuestionKeys = genStandQuestionList.stream() + .filter(e -> QuestionTypeEnum.GENERAL_COMBINATION.getCode().equals(e.getQuestionType())) + .map(StandardQuestionDto::getQuestionKey).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(combinationQuestionKeys)) { + return; + } + //查询组合题和小题(有序)的KEY缓存 + Map> combinationQuestionMap = + combinationQuestionCacheService.load(combinationQuestionKeys, ApiServerConfig.INSTANCE.isRedisEnabled()); + if (MapUtils.isEmpty(combinationQuestionMap)) { + return; + } + + //用小题的key集合,再调用一次`QuestionServiceImpl.listStandardQuestion` + List keys = combinationQuestionMap.values().stream().flatMap(Collection::stream) + .map(key -> { + StandardQuestionUniqueKeyReq standardQuestionUniqueKeyReq = new StandardQuestionUniqueKeyReq(); + standardQuestionUniqueKeyReq.setQuestionKey(key); + return standardQuestionUniqueKeyReq; + }).collect(Collectors.toList()); + + ListStandardQuestionReq listStandardQuestionReq = new ListStandardQuestionReq(); + listStandardQuestionReq.setKeys(keys); + listStandardQuestionReq.setCourseId(req.getCourseId()); + listStandardQuestionReq.setEnv(req.getEnv()); + IRpcResult> listIRpcResult = this.listStandardQuestion(listStandardQuestionReq); + if (!listIRpcResult.checkSuccess()) { + return; + } + List smallQuestionList = listIRpcResult.getData(); + if (CollectionUtils.isEmpty(smallQuestionList)) { + return; + } + Map smallQuestionMap = smallQuestionList.stream() + .collect(Collectors.toMap(StandardQuestionDto::getQuestionKey, Function.identity(), (v1, v2) -> v1)); + + // 将小题设置到组合题中 + genStandQuestionList.forEach(combinationQuestion -> { + List smallQuestionKeys = combinationQuestionMap.getOrDefault( + combinationQuestion.getQuestionKey(), + Collections.emptyList() + ); + if (smallQuestionKeys.isEmpty()) { + return; + } + + List smallQuestions = smallQuestionKeys.stream() + .map(smallQuestionMap::get) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + if (smallQuestions.isEmpty()) { + return; + } + + StandardQuestionDataDto questionData = Optional.ofNullable(combinationQuestion.getQuestionData()) + .orElseGet(StandardQuestionDataDto::new); + StandardQuestionInfoDto questionInfo = Optional.ofNullable(questionData.getQuestionInfo()) + .orElseGet(StandardQuestionInfoDto::new); + + questionInfo.setSmallQuestions(smallQuestions); + questionData.setQuestionInfo(questionInfo); + combinationQuestion.setQuestionData(questionData); + }); + } + + /** + * 查询益智标准题题内容信息 + * + * @return + */ + private List listYZStandQuestion(Set keys) { + if (CollectionUtils.isEmpty(keys)) { + return new ArrayList<>(0); + } + // 题key集合 + Set questionKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + // 皮肤key集合 + Set skinKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getGameSkinKey).collect(Collectors.toSet()); + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + // 题信息 + Map questionInfoMap = getQuestionInfo(questionKeys, redisEnabled); + List questionBaseInfoCacheBoList = questionInfoMap.values() + .stream() + .filter(e -> StringUtils.isNotBlank(e.getQuestionKey())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(questionBaseInfoCacheBoList)) { + return new ArrayList<>(0); + } + // 皮肤信息 + Map gameSkinBoMap = getGameSkin(skinKeys, redisEnabled); + if (MapUtils.isEmpty(gameSkinBoMap)) { + return new ArrayList<>(0); + } + // 查询交互形式 + Map interactiveFormBoMap = getInteractiveForm(gameSkinBoMap, redisEnabled); + if (MapUtils.isEmpty(interactiveFormBoMap)) { + return new ArrayList<>(0); + } + // 查询框架资源 + Set packResourceKey = interactiveFormBoMap.values().stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getResourcePackageKey())) + .map(InteractiveFormCacheBo::getResourcePackageKey) + .collect(Collectors.toSet()); + Map frameResourceKeyMap = getFrameResource(packResourceKey); + Set questionIds = questionBaseInfoCacheBoList.stream() + .map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(questionIds)) { + return new ArrayList<>(0); + } + // 获取题的的全部已经配置的内容规范组关联关系信息 <题ID,<内容规范ID,内容规范配置信息ID>> + Map> questionIdMap = contentNormConfigCacheService.load(questionIds, redisEnabled); + + // 查询内容组配 + Set configIds = new HashSet<>(); + // 数据组装 + List resultList = new ArrayList<>(); + for (StandardQuestionUniqueKeyReq key : keys) { + if (!questionInfoMap.containsKey(key.getQuestionKey())) { + continue; + } + QuestionBaseInfoCacheBo question = questionInfoMap.get(key.getQuestionKey()); + // 设置题信息 + StandardQuestionDto standardQuestionDto = new StandardQuestionDto(); + standardQuestionDto.setQuestionKey(key.getQuestionKey()); + standardQuestionDto.setGameSkinKey(key.getGameSkinKey()); + standardQuestionDto.setQuestionType(question.getQuestionType()); + standardQuestionDto.setQuestionCategory( + Optional.ofNullable(question.getQuestionCategory()) + .orElse(questionTypeBizService.getCategory(question.getQuestionType()).getOutCategory()) + ); + Integer realExamPaperSource = question.getRealExamPaperSource(); + standardQuestionDto.setRealExamPaperSource( + Objects.equals(realExamPaperSource, 0) ? null : realExamPaperSource); + + standardQuestionDto.setExamPaperSourceLabels(question.getExamPaperSourceLabels()); + standardQuestionDto.setExamPaperSources(ExamPaperSourceBo2DtoConvert.INSTANCE.to(question.getExamPaperSources())); + if (CollectionUtils.isNotEmpty(question.getExamPaperSourceLabels())) { + standardQuestionDto.setRealExamPaperSourceName(question.getExamPaperSourceLabels().getFirst()); + } else { + standardQuestionDto.setRealExamPaperSourceName(StringUtils.EMPTY); + } + + resultList.add(standardQuestionDto); + // 设置题数据 + GameSkinCacheBo gameSkin = gameSkinBoMap.get(key.getGameSkinKey()); + if (Objects.nonNull(gameSkin)) { + // 设置题皮肤 + StandardQuestionDataDto questionDataDto = new StandardQuestionDataDto(); + standardQuestionDto.setQuestionData(questionDataDto); + questionDataDto.setQuestionId(question.getQuestionId()); + questionDataDto.setGameSkinKey(key.getGameSkinKey()); + questionDataDto.setGameSkinName(gameSkin.getGameSkinName()); + UnityResourceDto skinRes = RpcCommonService.buildUnityResourceDto( + Optional.ofNullable(gameSkin.getResourcePackage()) + .map(ClientResourcePackageBo::getAndroid).orElse(null), + Optional.ofNullable(gameSkin.getResourcePackage()) + .map(ClientResourcePackageBo::getIos).orElse(null), + Optional.ofNullable(gameSkin.getResourcePackage()) + .map(ClientResourcePackageBo::getWeb).orElse(null), + CCBResourceService.CCBRES_FRAME_RESOURCE_PATH + ); + questionDataDto.setGameSkinRes(skinRes); + // 设置交互相关配置 题内容规范与题公共配置 + Long interactiveFormId = gameSkin.getInteractiveFormId(); + InteractiveFormCacheBo formBo = interactiveFormBoMap.get(interactiveFormId); + if (Objects.nonNull(formBo)) { + // 设置交互相关配置 + questionDataDto.setGameLogicKey(formBo.getInteractiveFormKey()); + questionDataDto.setGameLogicName(formBo.getInteractiveName()); + questionDataDto.setGameLogicRes( + RpcCommonService.convertFrameResourceDtoToUnityResourceDto( + frameResourceKeyMap.get(formBo.getResourcePackageKey()) + ) + ); + questionDataDto.setGameLogicScript(formBo.getCodeScriptName()); + //设置题内容组内容数据和题公共数据 + StandardQuestionInfoDto questionInfoDto = new StandardQuestionInfoDto(); + questionDataDto.setQuestionInfo(questionInfoDto); + questionInfoDto.setQuestionKey(key.getQuestionKey()); + questionInfoDto.setQuestionType(question.getQuestionType()); + Long configId = Optional.ofNullable(questionIdMap.get(question.getQuestionId())) + .map(map -> map.get(formBo.getContentNormId())).orElse(null); + if (Objects.nonNull(configId)) { + configIds.add(configId); + } + } + } + } + // 查询题内容json和题公共json + Pair, Map> gameJson = + getQuestionGameJson(questionIds, configIds, redisEnabled); + Map questionPublic = gameJson.getLeft(); + Map configIdMap = gameJson.getRight(); + // json地址填充 + List needSetJsonList = resultList.stream() + .filter(e -> Objects.nonNull(e.getQuestionData()) && Objects.nonNull(e.getQuestionData().getQuestionInfo())) + .collect(Collectors.toList()); + for (StandardQuestionDto dto : needSetJsonList) { + QuestionBaseInfoCacheBo question = questionInfoMap.get(dto.getQuestionKey()); + GameSkinCacheBo gameSkin = gameSkinBoMap.get(dto.getGameSkinKey()); + Long interactiveFormId = gameSkin.getInteractiveFormId(); + InteractiveFormCacheBo formBo = interactiveFormBoMap.get(interactiveFormId); + Long configId = Optional.ofNullable(questionIdMap.get(question.getQuestionId())) + .map(map -> map.get(formBo.getContentNormId())) + .orElse(null); + if (Objects.nonNull(configId)) { + String contentJson = Optional.ofNullable(configIdMap.get(configId)) + .map(FormAndDataCacheBo::getOutDataJson).orElse(StringUtils.EMPTY); + String publicJson = Optional.ofNullable(questionPublic.get(question.getQuestionId())) + .map(FormAndDataCacheBo::getOutDataJson).orElse(StringUtils.EMPTY); + StandardQuestionInfoDto questionInfo = dto.getQuestionData().getQuestionInfo(); + questionInfo.setData(UriToolsUtil.getPath(contentJson)); + questionInfo.setQuestionPublic(UriToolsUtil.getPath(publicJson)); + } + } + return resultList; + } + + private Pair, Map> getQuestionGameJson(Set questionIds, + Set configIds, + boolean redisEnabled) { + Set questionContentKey = configIds.stream() + .map(contentNormConfigId -> new FormAndDataCacheKey(contentNormConfigId, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType(), + OutPutDataTypeEnum.GAME.getType() + ) + ).collect(Collectors.toSet()); + + // 查询题公共配置的缓存key + Set questionPublicKey = questionIds.stream() + .map(questionId -> new FormAndDataCacheKey(questionId, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType(), + OutPutDataTypeEnum.GAME.getType()) + ).collect(Collectors.toSet()); + questionContentKey.addAll(questionPublicKey); + // 查询 + Map configIdMap = new HashMap<>(); + Map questionPublic = new HashMap<>(); + if (CollectionUtils.isNotEmpty(questionContentKey)) { + Map boMap = + formAndDataCacheService.load(questionContentKey, redisEnabled); + List formAndDataCacheBos = boMap.values().stream() + .filter(e -> Objects.nonNull(e) && Objects.nonNull(e.getBusinessId())).collect(Collectors.toList()); + configIdMap = formAndDataCacheBos.stream() + .filter(e -> Objects.equals(e.getBusinessTypeClassify(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType())) + .collect(Collectors.toMap(FormAndDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2)); + questionPublic = formAndDataCacheBos.stream() + .filter(e -> Objects.equals(e.getBusinessTypeClassify(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType())) + .collect(Collectors.toMap(FormAndDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2)); + } + return Pair.of(questionPublic, configIdMap); + } + + /** + * 为一批通用标准题装配英文划词序列。 + * + *

划词是衍生数据:读库、反序列化、词典解析中的任何异常都不得影响题目本体下发, + * 因此在本层兜底;无分词行或解析失败的题保持 {@code segments=null}。

+ */ + private void fillSegments(List questions, + Map selectedOutputs) { + if (CollectionUtils.isEmpty(questions)) { + return; + } + try { + Set keys = questions.stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getQuestionKey())) + .map(StandardQuestionDto::getQuestionKey) + .collect(Collectors.toSet()); + Map segmentsMap = + englishSegmentDeliveryBizService.resolveByQuestionKeys(keys, selectedOutputs); + if (MapUtils.isEmpty(segmentsMap)) { + return; + } + questions.stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getQuestionKey())) + .forEach(question -> question.setSegments(segmentsMap.get(question.getQuestionKey()))); + } catch (Exception e) { + log.error("装配英文划词失败,本次不下发 segments, size={}", questions.size(), e); + } + } + + /** + * 查询通用标准题题内容信息 + * + * @param keys + * @return + */ + private List listGenStandQuestion(Set keys) { + return listGenStandQuestion(keys, new HashMap<>()); + } + + private List listGenStandQuestion(Set keys, + Map selectedOutputs) { + if (CollectionUtils.isEmpty(keys)) { + return Collections.emptyList(); + } + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + //查询题信息 + Map questionInfoMap = getQuestionInfo(keys, redisEnabled); + List questionBaseInfoCacheBoList = questionInfoMap.values().stream() + .filter(e -> Objects.equals(e.getQuestionCategory(), + QuestionTypeCategoryEnum.GEN_STANDARD_QUESTION.getOutCategory()) || + Objects.equals(e.getQuestionCategory(), + QuestionTypeCategoryEnum.GEN_INTERACTION_QUESTION.getOutCategory())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(questionBaseInfoCacheBoList)) { + return Collections.emptyList(); + } + //查询布局信息 + Set layoutIds = questionInfoMap.values() + .stream().filter(e -> Objects.nonNull(e) && e.getLayoutId() > Constants.DEFAULT_ID) + .map(QuestionBaseInfoCacheBo::getLayoutId).collect(Collectors.toSet()); + Map layoutMap = templateLayoutCacheService.load(layoutIds, redisEnabled); + //查询模板信息 + Set templateIds = layoutMap.values().stream() + .map(TemplateLayoutCacheBo::getTemplateId).collect(Collectors.toSet()); + Map templateMap = + questionTypeTemplateCacheService.load(templateIds, redisEnabled); + //查询模板框架资源信息 + Set resKeys = templateMap.values().stream() + .map(QuestionTypeTemplateCacheBo::getResKey).collect(Collectors.toSet()); + Map frameResourceKeyMap = getFrameResource(resKeys); + // 获取题的的全部已经配置的内容规范组关联关系信息 <题ID,<内容规范ID,内容规范配置信息ID>> + Set questionIds = questionBaseInfoCacheBoList.stream() + .map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toSet()); + Map> questionIdMap = contentNormConfigCacheService.load(questionIds, redisEnabled); + // <题ID,内容规范配置信息ID> + Map questionConfigMap = questionIdMap.entrySet().stream().collect(Collectors.toMap(e -> e.getKey(), + e -> e.getValue().values().stream().findFirst().orElse(Constants.DEFAULT_ID), + (k1, k2) -> k2) + ); + Set configIds = questionConfigMap.values().stream().collect(Collectors.toSet()); + // 查询题内容json和题公共json + Pair, Map> gameJson = + getQuestionGameJson(questionIds, configIds, redisEnabled); + Map questionPublic = gameJson.getLeft(); + Map configIdMap = gameJson.getRight(); + //组装数据 + List resultList = new ArrayList<>(); + for (String questionKey : keys) { + QuestionBaseInfoCacheBo questionBaseInfo = questionInfoMap.get(questionKey); + if (Objects.isNull(questionBaseInfo)) { + continue; + } + TemplateLayoutCacheBo layoutBo = layoutMap.get(questionBaseInfo.getLayoutId()); + if (Objects.isNull(layoutBo)) { + continue; + } + QuestionTypeTemplateCacheBo templateBo = templateMap.get(layoutBo.getTemplateId()); + if (Objects.isNull(templateBo)) { + continue; + } + FrameResourceDto frameRes = frameResourceKeyMap.get(templateBo.getResKey()); + if (Objects.isNull(frameRes)) { + continue; + } + Long configId = questionConfigMap.get(questionBaseInfo.getQuestionId()); + FormAndDataCacheBo contentConfigBo = configIdMap.get(configId); + if (Objects.isNull(contentConfigBo)) { + continue; + } + StandardQuestionDto dto = new StandardQuestionDto(); + + Integer realExamPaperSource = questionBaseInfo.getRealExamPaperSource(); + dto.setRealExamPaperSource(Objects.equals(realExamPaperSource, 0) ? null : realExamPaperSource); + dto.setExamPaperSourceLabels(questionBaseInfo.getExamPaperSourceLabels()); + dto.setExamPaperSources(ExamPaperSourceBo2DtoConvert.INSTANCE.to(questionBaseInfo.getExamPaperSources())); + if (CollectionUtils.isNotEmpty(questionBaseInfo.getExamPaperSourceLabels())) { + dto.setRealExamPaperSourceName(questionBaseInfo.getExamPaperSourceLabels().getFirst()); + } else { + dto.setRealExamPaperSourceName(StringUtils.EMPTY); + } + + dto.setQuestionKey(questionKey); + dto.setQuestionType(questionBaseInfo.getQuestionType()); + dto.setQuestionCategory( + Optional.ofNullable(questionBaseInfo.getQuestionCategory()) + .orElse(questionTypeBizService.getCategory(questionBaseInfo.getQuestionType()).getOutCategory()) + ); + StandardQuestionDataDto questionData = new StandardQuestionDataDto(); + dto.setQuestionData(questionData); + questionData.setQuestionId(questionBaseInfo.getQuestionId()); + questionData.setLayoutSign(layoutBo.getLayoutSign()); + questionData.setTemplateSign(templateBo.getTemplateSign()); + questionData.setTemplateRes(RpcCommonService.convertFrameResourceDtoToUnityResourceDto( + frameResourceKeyMap.get(templateBo.getResKey()) + )); + //设置题内容组内容数据和题公共数据 + StandardQuestionInfoDto questionInfo = new StandardQuestionInfoDto(); + questionData.setQuestionInfo(questionInfo); + questionInfo.setQuestionKey(questionBaseInfo.getQuestionKey()); + questionInfo.setQuestionType(questionBaseInfo.getQuestionType()); + questionInfo.setData(UriToolsUtil.getPath(contentConfigBo.getOutDataJson())); + selectedOutputs.put(questionKey, cn.jojo.cc.biz.service.english.segment.SelectedSegmentOutput.capture( + cn.jojo.cc.core.multidatasource.MultiDataSourceContextHolder.isJYDatasource() + ? DataBaseTypeEnum.TEACHING : DataBaseTypeEnum.PROD, + configId, contentConfigBo, questionInfo.getData())); + questionInfo.setQuestionPublic( + UriToolsUtil.getPath(Optional.ofNullable(questionPublic.get(questionBaseInfo.getQuestionId())) + .map(FormAndDataCacheBo::getOutDataJson).orElse(StringUtils.EMPTY)) + ); + resultList.add(dto); + } + return resultList; + } + + + @Override + public IRpcResult> listReport(ListQuestionReportReq req) { + //通过Set去重 + Set keys = req.getKeys() + .stream() + .filter(e -> Objects.nonNull(e) && StringUtils.isNotBlank(e.getQuestionKey()) && StringUtils.isNotBlank(e.getGameSkinKey())) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(keys)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 题key集合 + Set questionKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + // 皮肤key集合 + Set skinKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getGameSkinKey).collect(Collectors.toSet()); + //是否开启redis + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + + // 缓存数据变量声明(合并加载或原始加载共用) + Map questionInfoMap = null; + Map skinCacheBoMap = null; + Map interactiveFormMap = null; + Map> questionIdMap = null; + List formDataList = null; + Map schemaMap = null; + + // 尝试使用合并缓存加载器(3次Redis Pipeline替代14次Batch) + boolean useMergedLoader = redisEnabled && ApiServerConfig.INSTANCE.isListReportMergedCacheLoaderEnabled(); + if (useMergedLoader) { + try { + QuestionReportCacheLoader.ReportCacheData cacheData = + questionReportCacheLoader.loadAll(questionKeys, skinKeys); + questionInfoMap = cacheData.getQuestionInfoMap(); + skinCacheBoMap = cacheData.getSkinCacheBoMap(); + interactiveFormMap = cacheData.getInteractiveFormMap(); + questionIdMap = cacheData.getContentNormConfigMap(); + formDataList = cacheData.getFormDataList(); + schemaMap = cacheData.getSchemaMap(); + } catch (Exception e) { + log.error("[QuestionService#listReport] 合并缓存加载失败,回退到原始加载方式", e); + useMergedLoader = false; + } + } + + if (!useMergedLoader) { + // 原始缓存加载路径(保留作为fallback) + // + questionInfoMap = getQuestionInfo(questionKeys, redisEnabled); + if (MapUtils.isEmpty(questionInfoMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 查询皮肤 + skinCacheBoMap = getGameSkin(skinKeys, redisEnabled); + if (MapUtils.isEmpty(skinCacheBoMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 查询交互形式 + interactiveFormMap = getInteractiveForm(skinCacheBoMap, redisEnabled); + if (MapUtils.isEmpty(interactiveFormMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 获取题的的全部已经配置的内容规范组关联关系信息 <题ID,<内容规范ID,内容规范配置信息ID>> + Set questionIds = questionInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(questionIds)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + questionIdMap = contentNormConfigCacheService.load(questionIds, redisEnabled); + if (MapUtils.isEmpty(questionIdMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 获取表单数据 + formDataList = getFormData(questionIdMap, questionInfoMap.values().stream().collect(Collectors.toList()), redisEnabled); + // 查询schema信息 + schemaMap = getSchemaInfo(questionInfoMap.values().stream().collect(Collectors.toList()), redisEnabled); + if (MapUtils.isEmpty(schemaMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + } + + if (MapUtils.isEmpty(questionInfoMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + List questionInfoList = questionInfoMap.values().stream().collect(Collectors.toList()); + if (MapUtils.isEmpty(skinCacheBoMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + if (MapUtils.isEmpty(interactiveFormMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + Set questionIds = questionInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(questionIds)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + if (MapUtils.isEmpty(questionIdMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + if (MapUtils.isEmpty(schemaMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 获取内容组的表单配置 + Map contentFormDataMap = formDataList.stream() + .filter(e -> Objects.equals(BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType(), e.getBusinessTypeClassify())) + .collect( + Collectors.toMap(FormDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2) + ); + if (MapUtils.isEmpty(contentFormDataMap)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + // 获取题解析表单数据 + Map explainFormDataMap = formDataList.stream() + .filter(e -> Objects.equals(BusinessTypeClassifyEnum.EXPLAINS.getType(), e.getBusinessTypeClassify())) + .collect( + Collectors.toMap(FormDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2) + ); + // 构建结果 + List resList = new ArrayList<>(); + for (StandardQuestionUniqueKeyReq key : keys) { + QuestionBaseInfoCacheBo questionCacheBo = questionInfoMap.get(key.getQuestionKey()); + if (Objects.isNull(questionCacheBo)) { + continue; + } + String gameSkinKey = key.getGameSkinKey(); + GameSkinCacheBo skinCacheBo = skinCacheBoMap.get(gameSkinKey); + if (Objects.isNull(skinCacheBo)) { + continue; + } + Long interactiveFormId = skinCacheBo.getInteractiveFormId(); + InteractiveFormCacheBo interactiveFormCacheBo = interactiveFormMap.get(interactiveFormId); + if (Objects.isNull(interactiveFormCacheBo)) { + continue; + } + BusinessSchemaCacheBo schema = schemaMap.get(questionCacheBo.getQuestionTypeId()); + if (Objects.isNull(schema)) { + continue; + } + Long contentNormId = interactiveFormCacheBo.getContentNormId(); + Map normConfigMap = questionIdMap.get(questionCacheBo.getQuestionId()); + if (MapUtils.isEmpty(normConfigMap)) { + continue; + } + Long configId = normConfigMap.get(contentNormId); + if (Objects.isNull(configId)) { + continue; + } + //第一步获取内容规范表单数据 + JSON contentJson = Optional.ofNullable(contentFormDataMap.get(configId)) + .map(FormDataCacheBo::getFormData).orElse(null); + //没有题内容的时候或者不是json对象的时候直接返回 + if (Objects.isNull(contentJson)|| !(contentJson instanceof JSONObject)) { + continue; + } + JSONObject contentNormJson = (JSONObject) contentJson; + //第二步获取解析数据 + JSON explainJson = Optional.ofNullable(explainFormDataMap.get(questionCacheBo.getQuestionId())) + .map(FormDataCacheBo::getFormData).orElse(null); + //合并 + contentNormJson.put(BusinessTypeClassifyEnum.EXPLAINS.getJsonKey(), explainJson); + //生成JSON数据 + BusinessSchemaCacheBo schemaCacheBo = schemaMap.get(questionCacheBo.getQuestionTypeId()); + if (Objects.isNull(schemaCacheBo) || StringUtils.isBlank(schemaCacheBo.getReportSchema())) { + continue; + } + JsonNode reportSchema = JsonSchemaUtil.strToJsonNode(schemaCacheBo.getReportSchema()); + if (Objects.isNull(reportSchema)) { + continue; + } + JSON dataJson; + try { + dataJson = JsonSchemaExtractDataUtil.buildGameSchemaData( + contentNormJson.toJSONString(), reportSchema, true + ); + } catch (Exception e) { + log.error("[QuestionService#listReport] 生产报告数据失败!questionKey:{},gameSkinKey:{}", key.getQuestionKey(), + key.getGameSkinKey(), e); + //将异常捕获 若获取schema失败之后 不阻断当前接口返回 + continue; + } + if (Objects.isNull(dataJson)) { + continue; + } + QuestionReportDto dto = new QuestionReportDto(); + dto.setQuestionKey(questionCacheBo.getQuestionKey()); + dto.setGameSkinKey(gameSkinKey); + dto.setIsPracticeQuestion(Constants.ONE); + dto.setQuestionType(questionCacheBo.getQuestionType()); + dto.setReportJson(dataJson.toJSONString()); + resList.add(dto); + } + + return DefaultRpcResult.successWithData(resList); + } + + @Override + public IRpcResult> listPrintableQuestions(PrintableQuestionReq req) { + List keys = req.getKeys().stream() + .filter(e -> StringUtils.isNotBlank(e.getQuestionKey())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(keys)) { + return DefaultRpcResult.successWithData(new ArrayList<>(0)); + } + Map> standardQuestionGroupReq = keys.stream() + .collect(Collectors.groupingBy(e -> StringUtils.isNotBlank(e.getGameSkinKey()))); + List nonStandardQuestionKeys = standardQuestionGroupReq.get(false); + List standardQuestionKeys = standardQuestionGroupReq.get(true); + List result = new ArrayList<>(); + + boolean redisEnabled = ApiServerConfig.INSTANCE.isRedisEnabled(); + boolean useMergedLoader = redisEnabled && ApiServerConfig.INSTANCE.isListPrintableMergedCacheLoaderEnabled(); + + if (useMergedLoader) { + try { + Set allQuestionKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + Set standardQuestionKeySet = CollectionUtils.isNotEmpty(standardQuestionKeys) + ? standardQuestionKeys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()) + : new HashSet<>(0); + Set skinKeys = CollectionUtils.isNotEmpty(standardQuestionKeys) + ? standardQuestionKeys.stream() + .map(StandardQuestionUniqueKeyReq::getGameSkinKey).collect(Collectors.toSet()) + : new HashSet<>(0); + + PrintableQuestionCacheLoader.PrintableCacheData cacheData = + printableQuestionCacheLoader.loadAll(allQuestionKeys, standardQuestionKeySet, skinKeys); + + // 非标准题组装 + if (CollectionUtils.isNotEmpty(nonStandardQuestionKeys)) { + result.addAll(buildNonStandardPrintResult(cacheData, nonStandardQuestionKeys)); + } + // 标准题组装 + if (CollectionUtils.isNotEmpty(standardQuestionKeys)) { + result.addAll(buildStandardPrintResult(cacheData, standardQuestionKeys)); + } + return DefaultRpcResult.successWithData(result); + } catch (Exception e) { + log.error("[QuestionService#listPrintableQuestions] 合并缓存加载失败,回退到原始加载方式", e); + result.clear(); + } + } + + // 原始路径(fallback) + List noStandQuestionPrintInfo = listNonStandardPrintableQuestions(nonStandardQuestionKeys); + if (CollectionUtils.isNotEmpty(noStandQuestionPrintInfo)) { + result.addAll(noStandQuestionPrintInfo); + } + List standQuestionPrintInfo = listStandardPrintableQuestions(standardQuestionKeys); + if (CollectionUtils.isNotEmpty(standQuestionPrintInfo)) { + result.addAll(standQuestionPrintInfo); + } + return DefaultRpcResult.successWithData(result); + } + + @Override + public IRpcResult> listVerifyQuestion(ListVerifyQuestionReq req) { + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + Map questionMap = getQuestionInfo(req.getQuestionKeys(), useCache); + if (MapUtils.isEmpty(questionMap)) { + return DefaultRpcResult.successWithData(Maps.newHashMapWithExpectedSize(0)); + } + List questionList = questionMap.values().stream().collect(Collectors.toList()); + // 查询验证组 + List questionIds = questionList.stream().map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toList()); + List verifyList = listCollectionBusinessIdAndType(questionIds, BusinessTypeEnum.QUESTION_VERIFY_QUESTION); + if (CollectionUtils.isEmpty(verifyList)) { + return DefaultRpcResult.successWithData(Maps.newHashMapWithExpectedSize(0)); + } + // 查询验证组下的题 + Set verifyGroupIds = verifyList.stream().map(CollectionBusinessDo::getCollectionId).collect(Collectors.toSet()); + List elementDoList = useCache ? + listCollectionElementByCollectionIdsCache(verifyGroupIds) : + listCollectionElementByCollectionIdsDb(verifyGroupIds); + elementDoList = elementDoList.stream() + .filter(e -> ElementRoleEnum.VERIFY_QUESTION.getCode().equals(e.getElementRole())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(elementDoList)) { + return DefaultRpcResult.successWithData(Maps.newHashMapWithExpectedSize(0)); + } + // 查询验证题的基础信息 + Set verifyQuestionIds = + elementDoList.stream().map(CollectionElementDo::getElementId).collect(Collectors.toSet()); + List questionDtoList = questionManager.listQuestionByIds(verifyQuestionIds); + //数据组装 + Map> questionIdVerifyMap = + verifyList.stream().collect(Collectors.groupingBy(CollectionBusinessDo::getBusinessId)); + Map> groupQuestionMap = elementDoList.stream() + .collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)); + Map verifyQuestionMap = questionDtoList.stream().collect( + Collectors.toMap(QuestionDto::getQuestionId, Function.identity(), (v1, v2) -> v2)); + Map verifyQuestionMapResult = new HashMap<>(); + for (String questionKey : req.getQuestionKeys()) { + QuestionBaseInfoCacheBo questionBo = questionMap.get(questionKey); + if (Objects.isNull(questionBo)) { + continue; + } + List verifyGroups = questionIdVerifyMap.get(questionBo.getQuestionId()); + if (CollectionUtils.isEmpty(verifyGroups)) { + continue; + } + List verifyGroupQuestionDtoList = new ArrayList<>(); + for (CollectionBusinessDo verifyGroup : verifyGroups) { + List verifyQuestionList = groupQuestionMap.get(verifyGroup.getCollectionId()); + if (CollectionUtils.isEmpty(verifyQuestionList)) { + continue; + } + List verifyQuestionListDto = verifyQuestionList.stream().map(e -> + Optional.ofNullable(verifyQuestionMap.get(e.getElementId())) + .map(QuestionDto::getQuestionDefinition) + .orElse(null) + ).filter(Objects::nonNull).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(verifyQuestionListDto)) { + continue; + } + VerifyGroupQuestionDto verifyGroupQuestionDto = new VerifyGroupQuestionDto(); + verifyGroupQuestionDto.setCollectionId(verifyGroup.getCollectionId()); + verifyGroupQuestionDto.setSortOrder(verifyGroup.getSortOrder()); + verifyGroupQuestionDto.setVerifyQuestionList(verifyQuestionListDto); + verifyGroupQuestionDtoList.add(verifyGroupQuestionDto); + } + if (CollectionUtils.isNotEmpty(verifyGroupQuestionDtoList)) { + VerifyQuestionDto dto = new VerifyQuestionDto(); + dto.setVerifyQuestionGroupList(verifyGroupQuestionDtoList); + verifyQuestionMapResult.put(questionKey, dto); + } + } + return DefaultRpcResult.successWithData(verifyQuestionMapResult); + } + + private List listCollectionBusinessIdAndType(Collection businessId, BusinessTypeEnum bsType) { + if (ApiServerConfig.INSTANCE.isRedisEnabled()) { + return listCollectionBusinessIdAndTypeCache( + businessId, bsType + ); + } + return collectionBusinessCoreService.listValidByBusinessIds(businessId, bsType); + } + + /** + * 查询非标准题信息 + * @param keys 非标准题keys + */ + private List listNonStandardPrintableQuestions(List keys) { + if (CollectionUtils.isEmpty(keys)) { + return new ArrayList<>(0); + } + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + Set questionKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + Map questionInfoMap = getQuestionInfo(questionKeys, useCache); + if (MapUtils.isEmpty(questionInfoMap)) { + return new ArrayList<>(0); + } + Set questionIds = questionInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toSet()); + Map questionContentDoMap = questionContentByIdCacheService.load(questionIds, useCache); + if (MapUtils.isEmpty(questionContentDoMap)) { + return new ArrayList<>(0); + } + return questionInfoMap.values().stream().map(baseInfo -> { + PrintableQuestionDto questionDto = new PrintableQuestionDto(); + questionDto.setQuestionId(baseInfo.getQuestionId()); + questionDto.setQuestionKey(baseInfo.getQuestionKey()); + questionDto.setQuestionType(baseInfo.getQuestionType()); + QuestionContentDo questionContentDo = questionContentDoMap.get(baseInfo.getQuestionId()); + if (questionContentDo == null) { + return null; + } + String content = questionContentDo.getContent(); + // question -> content + if (StringUtils.isNotBlank(content)) { + try { + questionDto.setData(JsonStoreUtil.parse(content)); + } catch (Exception e) { + log.info("parse content error, questionId={}", baseInfo.getQuestionId(), e); + return null; + } + } + return questionDto; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 查询通用标准题数据内容 + * + * @param questionKeys + * @return + */ + public List listGenerateStandQuestion(Set questionKeys) { + if (CollectionUtils.isEmpty(questionKeys)) { + return new ArrayList<>(0); + } + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + Map questionInfoMap = getQuestionInfo(questionKeys, useCache); + if (MapUtils.isEmpty(questionInfoMap)) { + return new ArrayList<>(0); + } + // 获取题的的全部已经配置的内容规范组关联关系信息 <题ID,<内容规范ID,内容规范配置信息ID>> + Set questionIds = questionInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId) + .collect(Collectors.toSet()); + Map> questionIdMap = contentNormConfigCacheService.load(questionIds, useCache); + // <题ID,内容规范配置信息ID> + Map questionConfigMap = questionIdMap.entrySet().stream().collect( + Collectors.toMap(e -> e.getKey(), + e -> e.getValue().values().stream().findFirst().orElse(Constants.DEFAULT_ID), (k1, k2) -> k2) + ); + Set configIds = questionConfigMap.values().stream().collect(Collectors.toSet()); + // 查询题内容json和题公共json + Pair, Map> gameJson = + getQuestionGameJson(questionIds, configIds, useCache); + Map questionPublic = + Optional.ofNullable(gameJson.getLeft()).orElse(new HashMap<>(0)); + Map configIdMap = + Optional.ofNullable(gameJson.getRight()).orElse(new HashMap<>(0)); + List result = new ArrayList<>(); + for (String questionKey : questionKeys) { + QuestionBaseInfoCacheBo questionInfo = questionInfoMap.get(questionKey); + if (Objects.isNull(questionInfo)) { + continue; + } + Long configId = questionConfigMap.get(questionInfo.getQuestionId()); + if (Objects.isNull(configId) || Objects.equals(configId, Constants.DEFAULT_ID)) { + continue; + } + FormAndDataCacheBo contentNormData = configIdMap.get(configId); + if (Objects.isNull(contentNormData)) { + continue; + } + FormAndDataCacheBo questionPublicData = questionPublic.get(questionInfo.getQuestionId()); + PrintableQuestionDto dto = new PrintableQuestionDto(); + dto.setQuestionId(questionInfo.getQuestionId()); + dto.setQuestionKey(questionKey); + dto.setQuestionType(questionInfo.getQuestionType()); + JSONObject printJson = new JSONObject(); + printJson.put(Constants.PRINT_STAND_QUESTION_INFO_KEY, contentNormData.getOutDataJsonObj()); + if (Objects.nonNull(questionPublicData)) { + printJson.putAll((JSONObject) questionPublicData.getOutDataJsonObj()); + } + dto.setData(JSON.toJSONString(printJson, SerializerFeature.WriteMapNullValue)); + result.add(dto); + } + return result; + } + + /** + * 查询标准题打印数据 + * + * @param keys + * @return + */ + public List listStandardPrintableQuestions(List keys) { + if (CollectionUtils.isEmpty(keys)) { + return new ArrayList<>(0); + } + boolean useCache = ApiServerConfig.INSTANCE.isRedisEnabled(); + Set questionKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + Set gameSkinKeys = keys.stream() + .map(StandardQuestionUniqueKeyReq::getGameSkinKey).collect(Collectors.toSet()); + Map questionInfoMap = getQuestionInfo(questionKeys, useCache); + if (MapUtils.isEmpty(questionInfoMap)) { + return new ArrayList<>(0); + } + // 查询皮肤 + Map skinCacheBoMap = getGameSkin(gameSkinKeys, useCache); + // 查询交互形式 + Map interactiveFormMap = getInteractiveForm(skinCacheBoMap, useCache); + if (MapUtils.isEmpty(interactiveFormMap)) { + return new ArrayList<>(0); + } + // 获取题的的全部已经配置的内容规范组关联关系信息 <题ID,<内容规范ID,内容规范配置信息ID>> + Set questionIds = questionInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(questionIds)) { + return new ArrayList<>(0); + } + // <题ID,<内容规范ID,内容规范配置信息ID>> + Map> questionIdMap = contentNormConfigCacheService.load(questionIds, useCache); + if (MapUtils.isEmpty(questionIdMap)) { + return new ArrayList<>(0); + } + Set configIds = questionIdMap.values() + .stream().flatMap(e -> e.values().stream()) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(configIds)) { + return new ArrayList<>(0); + } + // 构建查询题内容配置的缓存key + Set questionContentKey = configIds.stream() + .map(contentNormConfigId -> new FormAndDataCacheKey(contentNormConfigId, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType(), OutPutDataTypeEnum.GAME.getType()) + ).collect(Collectors.toSet()); + // 查询题公共配置的缓存key + Set questionPublicKey = questionIds.stream() + .map(questionId -> new FormAndDataCacheKey(questionId, FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType(), OutPutDataTypeEnum.GAME.getType()) + ).collect(Collectors.toSet()); + questionContentKey.addAll(questionPublicKey); + // 查询 + Map configIdMap = new HashMap<>(); + Map questionPublicMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(questionContentKey)) { + Map boMap = + formAndDataCacheService.load(questionContentKey, useCache); + List formAndDataCacheBos = boMap.values().stream() + .filter(e -> Objects.nonNull(e) && Objects.nonNull(e.getBusinessId()) && Objects.nonNull(e.getOutDataJsonObj())) + .collect(Collectors.toList()); + configIdMap = formAndDataCacheBos.stream() + .filter(e -> + Objects.equals(e.getBusinessTypeClassify(), BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()) + ) + .collect(Collectors.toMap(FormAndDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2)); + questionPublicMap = formAndDataCacheBos.stream() + .filter(e -> Objects.equals(e.getBusinessTypeClassify(), BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType())) + .collect(Collectors.toMap(FormAndDataCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2)); + } + List result = new ArrayList<>(); + for (StandardQuestionUniqueKeyReq key : keys) { + String questionKey = key.getQuestionKey(); + String gameSkinKey = key.getGameSkinKey(); + QuestionBaseInfoCacheBo questionInfo = questionInfoMap.get(questionKey); + if (Objects.isNull(questionInfo)) { + continue; + } + GameSkinCacheBo skinInfoBo = skinCacheBoMap.get(gameSkinKey); + if (Objects.isNull(skinInfoBo)) { + continue; + } + InteractiveFormCacheBo interactiveFormInfo = interactiveFormMap.get(skinInfoBo.getInteractiveFormId()); + if (Objects.isNull(interactiveFormInfo)) { + continue; + } + Map contentNormConfigIdMap = questionIdMap.get(questionInfo.getQuestionId()); + if (MapUtils.isEmpty(contentNormConfigIdMap)) { + continue; + } + Long configId = contentNormConfigIdMap.get(interactiveFormInfo.getContentNormId()); + if (Objects.isNull(configId)) { + continue; + } + FormAndDataCacheBo contentNormData = configIdMap.get(configId); + if (Objects.isNull(contentNormData)) { + continue; + } + FormAndDataCacheBo questionPublicData = questionPublicMap.get(questionInfo.getQuestionId()); + PrintableQuestionDto dto = new PrintableQuestionDto(); + dto.setQuestionId(questionInfo.getQuestionId()); + dto.setQuestionKey(questionKey); + dto.setQuestionType(questionInfo.getQuestionType()); + + JSONObject printJson = new JSONObject(); + printJson.put(Constants.PRINT_STAND_QUESTION_INFO_KEY, contentNormData.getOutDataJsonObj()); + if (Objects.nonNull(questionPublicData)) { + printJson.putAll((JSONObject) questionPublicData.getOutDataJsonObj()); + } + dto.setData(JSON.toJSONString(printJson,SerializerFeature.WriteMapNullValue)); + result.add(dto); + } + return result; + } + + /** + * 从合并缓存数据组装非标准题打印结果 + *

+ * 逻辑与原listNonStandardPrintableQuestions一致: + * 1. 从questionInfoMap中筛选非标准题条目 + * 2. 用questionContentMap填充content数据 + * 3. content为空/null的条目过滤掉 + */ + private List buildNonStandardPrintResult( + PrintableQuestionCacheLoader.PrintableCacheData cacheData, + List keys) { + Map questionInfoMap = cacheData.getQuestionInfoMap(); + Map questionContentMap = cacheData.getQuestionContentMap(); + if (MapUtils.isEmpty(questionInfoMap)) { + return new ArrayList<>(0); + } + // 仅筛选非标准题的questionKey,避免混入标准题数据 + Set nonStandardKeySet = keys.stream() + .map(StandardQuestionUniqueKeyReq::getQuestionKey).collect(Collectors.toSet()); + // 仅取非标准题的questionInfo(与原listNonStandardPrintableQuestions逻辑一致) + Map nonStandardInfoMap = questionInfoMap.entrySet().stream() + .filter(e -> nonStandardKeySet.contains(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue, (k1, k2) -> k2)); + + // questionContentMap仅含非标准题content,空则提前返回 + Set nonStandardQuestionIds = nonStandardInfoMap.values().stream() + .map(QuestionBaseInfoCacheBo::getQuestionId).collect(Collectors.toSet()); + Set contentAvailableIds = nonStandardQuestionIds.stream() + .filter(id -> questionContentMap.containsKey(id)).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(contentAvailableIds)) { + return new ArrayList<>(0); + } + return nonStandardInfoMap.values().stream() + .map(baseInfo -> { + PrintableQuestionDto dto = new PrintableQuestionDto(); + dto.setQuestionId(baseInfo.getQuestionId()); + dto.setQuestionKey(baseInfo.getQuestionKey()); + dto.setQuestionType(baseInfo.getQuestionType()); + QuestionContentDo contentDo = questionContentMap.get(baseInfo.getQuestionId()); + if (contentDo == null) { + return null; + } + String content = contentDo.getContent(); + if (StringUtils.isNotBlank(content)) { + try { + dto.setData(JsonStoreUtil.parse(content)); + } catch (Exception e) { + log.info("parse content error, questionId={}", baseInfo.getQuestionId(), e); + return null; + } + } + return dto; + }).filter(Objects::nonNull).collect(Collectors.toList()); + } + + /** + * 从合并缓存数据组装标准题打印结果 + *

+ * 逻辑与原listStandardPrintableQuestions L2290-2330一致: + * 逐key查找 questionInfo → skin → interactiveForm → contentNormConfigId → configId → contentNormData → questionPublicData + * 任一环节为null/empty则跳过该题 + */ + private List buildStandardPrintResult( + PrintableQuestionCacheLoader.PrintableCacheData cacheData, + List keys) { + Map questionInfoMap = cacheData.getQuestionInfoMap(); + Map skinCacheBoMap = cacheData.getSkinCacheBoMap(); + Map interactiveFormMap = cacheData.getInteractiveFormMap(); + Map> contentNormConfigMap = cacheData.getContentNormConfigMap(); + Map configIdMap = cacheData.getConfigIdMap(); + Map questionPublicMap = cacheData.getQuestionPublicMap(); + + if (MapUtils.isEmpty(questionInfoMap) || MapUtils.isEmpty(skinCacheBoMap) + || MapUtils.isEmpty(interactiveFormMap) || MapUtils.isEmpty(contentNormConfigMap)) { + return new ArrayList<>(0); + } + + // 与原listStandardPrintableQuestions L2253-L2258一致:configIds为空时提前返回 + Set configIds = contentNormConfigMap.values().stream() + .flatMap(e -> e.values().stream()).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(configIds)) { + return new ArrayList<>(0); + } + + List result = new ArrayList<>(); + for (StandardQuestionUniqueKeyReq key : keys) { + QuestionBaseInfoCacheBo questionInfo = questionInfoMap.get(key.getQuestionKey()); + if (Objects.isNull(questionInfo)) { + continue; + } + GameSkinCacheBo skinInfoBo = skinCacheBoMap.get(key.getGameSkinKey()); + if (Objects.isNull(skinInfoBo)) { + continue; + } + InteractiveFormCacheBo interactiveFormInfo = interactiveFormMap.get(skinInfoBo.getInteractiveFormId()); + if (Objects.isNull(interactiveFormInfo)) { + continue; + } + Map contentNormConfigIdMap = contentNormConfigMap.get(questionInfo.getQuestionId()); + if (MapUtils.isEmpty(contentNormConfigIdMap)) { + continue; + } + Long configId = contentNormConfigIdMap.get(interactiveFormInfo.getContentNormId()); + if (Objects.isNull(configId)) { + continue; + } + FormAndDataCacheBo contentNormData = configIdMap.get(configId); + if (Objects.isNull(contentNormData)) { + continue; + } + + FormAndDataCacheBo questionPublicData = questionPublicMap.get(questionInfo.getQuestionId()); + PrintableQuestionDto dto = new PrintableQuestionDto(); + dto.setQuestionId(questionInfo.getQuestionId()); + dto.setQuestionKey(key.getQuestionKey()); + dto.setQuestionType(questionInfo.getQuestionType()); + JSONObject printJson = new JSONObject(); + printJson.put(Constants.PRINT_STAND_QUESTION_INFO_KEY, contentNormData.getOutDataJsonObj()); + if (Objects.nonNull(questionPublicData)) { + printJson.putAll((JSONObject) questionPublicData.getOutDataJsonObj()); + } + dto.setData(JSON.toJSONString(printJson, SerializerFeature.WriteMapNullValue)); + result.add(dto); + } + return result; + } + + private Map getQuestionInfo(Set questionKeys, boolean redisEnabled) { + if (CollectionUtils.isEmpty(questionKeys)) { + return new HashMap<>(0); + } + Map questionBaseInfoCacheBoMap = + questionBaseInfoCacheService.load(questionKeys, redisEnabled); + if (MapUtils.isEmpty(questionBaseInfoCacheBoMap)) { + return new HashMap<>(0); + } + questionBaseInfoCacheBoMap = questionBaseInfoCacheBoMap.values() + .stream() + .filter(e -> StringUtils.isNotBlank(e.getQuestionKey())) + .collect( + Collectors.toMap(QuestionBaseInfoCacheBo::getQuestionKey, + Function.identity(), + (k1, k2) -> k2) + ); + return questionBaseInfoCacheBoMap; + } + + private Map getGameSkin(Set gameSkins, boolean redisEnabled) { + if (CollectionUtils.isEmpty(gameSkins)) { + return new HashMap<>(0); + } + Map skinCacheBoMap = gameSkinCacheService.load(gameSkins, redisEnabled); + if (MapUtils.isEmpty(skinCacheBoMap)) { + return new HashMap<>(0); + } + skinCacheBoMap= skinCacheBoMap.values().stream() + .filter(e -> StringUtils.isNotBlank(e.getGameSkinKey())) + .collect(Collectors.toMap(GameSkinCacheBo::getGameSkinKey, Function.identity(), (k1, k2) -> k2)); + return skinCacheBoMap; + } + + private Map getInteractiveForm(Map gameSkins, boolean redisEnabled) { + if (MapUtils.isEmpty(gameSkins)) { + return new HashMap<>(0); + } + Set interactiveFormIds = gameSkins.values().stream() + .map(GameSkinCacheBo::getInteractiveFormId) + .collect(Collectors.toSet()); + Map interactiveFormBoMap = + interactiveFormCacheService.load(interactiveFormIds, redisEnabled); + if (MapUtils.isEmpty(interactiveFormBoMap)) { + return new HashMap<>(0); + } + interactiveFormBoMap = interactiveFormBoMap.values() + .stream() + .filter(e -> Objects.nonNull(e.getInteractiveFormId())) + .collect(Collectors.toMap(e -> e.getInteractiveFormId(), Function.identity(), (k1, k2) -> k2)); + return interactiveFormBoMap; + } + + private List getFormData(Map> questionIdMap, + List questionInfoList, + boolean redisEnabled) { + if (MapUtils.isEmpty(questionIdMap)) { + return new ArrayList<>(0); + } + Set configIds = questionIdMap.values() + .stream().flatMap(e -> e.values().stream()).collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(configIds)) { + return new ArrayList<>(0); + } + Set contentNormKeys = configIds.stream().map(e -> new FormDataCacheKey(e, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType())) + .collect(Collectors.toSet()); + if (CollectionUtils.isNotEmpty(questionInfoList)) { + Set explainsKeys = questionInfoList.stream().map(e -> new FormDataCacheKey(e.getQuestionId(), + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), BusinessTypeClassifyEnum.EXPLAINS.getType())) + .collect(Collectors.toSet()); + contentNormKeys.addAll(explainsKeys); + } + Map fromDataCacheMap = + formDataCacheService.load(contentNormKeys, redisEnabled); + if (MapUtils.isEmpty(fromDataCacheMap)) { + return new ArrayList<>(0); + } + List formDataCacheList = fromDataCacheMap.values().stream() + .filter(e->Objects.nonNull(e) && Objects.nonNull(e.getFormData()) && Objects.nonNull(e.getBusinessId())) + .collect(Collectors.toList()); + return formDataCacheList; + } + + private Map getSchemaInfo(List questionInfoList, + boolean redisEnabled) { + if (CollectionUtils.isEmpty(questionInfoList)) { + return new HashMap<>(0); + } + Set questionTypeIds = questionInfoList.stream() + .map(QuestionBaseInfoCacheBo::getQuestionTypeId) + .collect(Collectors.toSet()); + if (CollectionUtils.isEmpty(questionTypeIds)) { + return new HashMap<>(0); + } + Set schemaKeys = questionTypeIds.stream() + .map(id -> new BusinessSchemaCacheKey(id, BusinessSchemaRelTypeEnum.STANDARD_QUESTION_CONTENT.getType())) + .collect(Collectors.toSet()); + Map businessSchemaCacheMap = + businessSchemaCacheService.load(schemaKeys, redisEnabled); + if (MapUtils.isEmpty(businessSchemaCacheMap)) { + return new HashMap<>(0); + } + Map questionTypeSchemaMap = businessSchemaCacheMap.values() + .stream() + .filter(e -> StringUtils.isNotBlank(e.getReportSchema())) + .collect(Collectors.toMap(BusinessSchemaCacheBo::getBusinessId, Function.identity(), (k1, k2) -> k2)); + return questionTypeSchemaMap; + } + + private Map getFrameResource(Set packResourceKey) { + if (CollectionUtils.isEmpty(packResourceKey)) { + return new HashMap<>(0); + } + ListFrameResourceReq req = new ListFrameResourceReq(); + req.setKeys(packResourceKey); + IRpcResult> frameResourceResult = frameResourceService.listFrameResource(req); + Map frameResourceKeyMap = Optional.ofNullable(frameResourceResult.getData()) + .orElse(new ArrayList<>(0)) + .stream() + .collect(Collectors.toMap(FrameResourceDto::getKey, Function.identity(), (k1, k2) -> k2)); + return frameResourceKeyMap; + } + + private List listLessonSameQuestionsFromDb(Collection lessonIds) { + List response = new ArrayList<>(); + if (CollectionUtils.isEmpty(lessonIds)) { + return response; + } + + // lessonId => LessonSameQuestionsDto + Map lessonSameQuestionsDtoMap = new HashMap<>(lessonIds.size()); + for (Long lessonId : lessonIds) { + LessonSameQuestionsDto lessonSameQuestionsDto = new LessonSameQuestionsDto(); + response.add(lessonSameQuestionsDto); + + lessonSameQuestionsDto.setLessonId(lessonId); + lessonSameQuestionsDto.setSameQuestions(new ArrayList<>()); + + lessonSameQuestionsDtoMap.put(lessonId, lessonSameQuestionsDto); + } + + List businesses = + collectionBusinessCoreService.listValidByBusinessIds(lessonIds, BusinessTypeEnum.LESSON); + if (CollectionUtils.isNotEmpty(businesses)) { + // lessonId => List + Map> lessonCollectionMap = new HashMap<>(); + Set collectionIds = new HashSet<>(businesses.size()); + for (CollectionBusinessDo business : businesses) { + Long collectionId = business.getCollectionId(); + Long businessId = business.getBusinessId(); + collectionIds.add(collectionId); + + List collectionBusinessDoList; + if (lessonCollectionMap.containsKey(businessId)) { + collectionBusinessDoList = lessonCollectionMap.get(businessId); + } else { + collectionBusinessDoList = new ArrayList<>(); + lessonCollectionMap.put(business.getBusinessId(), collectionBusinessDoList); + } + collectionBusinessDoList.add(business); + } + lessonCollectionMap.forEach((lessonId, collectionBusinessDoList) -> { + LessonSameQuestionsDto lessonSameQuestionsDto = lessonSameQuestionsDtoMap.get(lessonId); + List sameQuestionDtoList = + lessonSameQuestionsDto.getSameQuestions(); + + for (CollectionBusinessDo collectionBusinessDo : collectionBusinessDoList) { + LessonSameQuestionsDto.SameQuestionDto sameQuestionDto = new LessonSameQuestionsDto.SameQuestionDto(); + sameQuestionDtoList.add(sameQuestionDto); + + sameQuestionDto.setLessonId(lessonId); + sameQuestionDto.setCollectionId(collectionBusinessDo.getCollectionId()); + sameQuestionDto.setSortOrder(collectionBusinessDo.getSortOrder()); + } + }); + // 构建母子题 + List elements = collectionElementCoreService.listByCollectionIds( + collectionIds, + ElementTypeEnum.QUESTION_LIB + ); + if (CollectionUtils.isNotEmpty(elements)) { + Set questionIds = new HashSet<>(); + // key:collectionId => value:collectionElementDos + Map> collectionElementsMap = new HashMap<>(); + for (CollectionElementDo element : elements) { + Long collectionId = element.getCollectionId(); + List elementDoList; + if (collectionElementsMap.containsKey(collectionId)) { + elementDoList = collectionElementsMap.get(collectionId); + } else { + elementDoList = new ArrayList<>(); + collectionElementsMap.put(collectionId, elementDoList); + } + elementDoList.add(element); + questionIds.add(element.getElementId()); + } + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(questionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + // questionId => QuestionSetDto + Map questionSetDtoMap = Optional.ofNullable(questionSetDtoList) + .orElse(new ArrayList<>(0)) + .stream() + .collect( + Collectors.toMap(QuestionSetDto::getQuestionId, Function.identity(), (v1, v2) -> v1) + ); + lessonCollectionMap.forEach((lessonId, collectionBusinessDoList) -> { + LessonSameQuestionsDto lessonSameQuestionsDto = lessonSameQuestionsDtoMap.get(lessonId); + List sameQuestions = + lessonSameQuestionsDto.getSameQuestions(); + for (LessonSameQuestionsDto.SameQuestionDto sameQuestion : sameQuestions) { + Long collectionId = sameQuestion.getCollectionId(); + List elementDoList = collectionElementsMap.get(collectionId); + List subQuestions = new ArrayList<>(); + for (CollectionElementDo collectionElementDo : elementDoList) { + Long questionId = collectionElementDo.getElementId(); + // 有可能题没有发布 + if (questionSetDtoMap.containsKey(questionId)) { + QuestionSetDto questionSet = questionSetDtoMap.get(questionId); + if (ElementRoleEnum.MAIN_QUESTION.getCode().equals(collectionElementDo.getElementRole())) { + LessonSameQuestionsDto.SameQuestionDto.QuestionSet mainQuestion = + new LessonSameQuestionsDto.SameQuestionDto.QuestionSet(); + sameQuestion.setMainQuestion(mainQuestion); + + mainQuestion.setLessonId(sameQuestion.getLessonId()); + mainQuestion.setQuestion(questionSet.getQuestion()); + mainQuestion.setElementRole(collectionElementDo.getElementRole()); + mainQuestion.setStepQuestions(questionSet.getStepQuestions()); + } else { + LessonSameQuestionsDto.SameQuestionDto.QuestionSet subQuestion = + new LessonSameQuestionsDto.SameQuestionDto.QuestionSet(); + subQuestions.add(subQuestion); + + subQuestion.setLessonId(sameQuestion.getLessonId()); + subQuestion.setQuestion(questionSet.getQuestion()); + subQuestion.setElementRole(collectionElementDo.getElementRole()); + subQuestion.setStepQuestions(questionSet.getStepQuestions()); + } + } + } + sameQuestion.setSubQuestions(subQuestions); + } + }); + } + } + + return response; + } + + private List listLessonSameQuestionsFromCache(Collection lessonIds) { + List response = new ArrayList<>(); + if (CollectionUtils.isEmpty(lessonIds)) { + return response; + } + + // lessonId => LessonSameQuestionsDto + Map lessonSameQuestionsDtoMap = new HashMap<>(lessonIds.size()); + for (Long lessonId : lessonIds) { + LessonSameQuestionsDto lessonSameQuestionsDto = new LessonSameQuestionsDto(); + response.add(lessonSameQuestionsDto); + + lessonSameQuestionsDto.setLessonId(lessonId); + lessonSameQuestionsDto.setSameQuestions(new ArrayList<>()); + + lessonSameQuestionsDtoMap.put(lessonId, lessonSameQuestionsDto); + } + // 获取collection_business + // lessonId => Set + List businesses = listCollectionBusinessIdAndTypeCache(lessonIds,BusinessTypeEnum.LESSON); + // 构建相似题组 + if (CollectionUtils.isNotEmpty(businesses)) { + // lessonId => List + Map> lessonCollectionMap = new HashMap<>(); + Set collectionIds = new HashSet<>(businesses.size()); + for (CollectionBusinessDo business : businesses) { + Long collectionId = business.getCollectionId(); + Long businessId = business.getBusinessId(); + collectionIds.add(collectionId); + + List collectionBusinessDoList; + if (lessonCollectionMap.containsKey(businessId)) { + collectionBusinessDoList = lessonCollectionMap.get(businessId); + } else { + collectionBusinessDoList = new ArrayList<>(); + lessonCollectionMap.put(business.getBusinessId(), collectionBusinessDoList); + } + collectionBusinessDoList.add(business); + } + lessonCollectionMap.forEach((lessonId, collectionBusinessDoList) -> { + LessonSameQuestionsDto lessonSameQuestionsDto = lessonSameQuestionsDtoMap.get(lessonId); + List sameQuestionDtoList = new ArrayList<>(); + lessonSameQuestionsDto.setSameQuestions(sameQuestionDtoList); + + for (CollectionBusinessDo collectionBusinessDo : collectionBusinessDoList) { + LessonSameQuestionsDto.SameQuestionDto sameQuestionDto = new LessonSameQuestionsDto.SameQuestionDto(); + sameQuestionDtoList.add(sameQuestionDto); + + sameQuestionDto.setLessonId(lessonId); + sameQuestionDto.setCollectionId(collectionBusinessDo.getCollectionId()); + sameQuestionDto.setSortOrder(collectionBusinessDo.getSortOrder()); + } + }); + + // 获取collection_element + List elements = listCollectionElementByCollectionIdsCache(collectionIds); + + if (CollectionUtils.isNotEmpty(elements)) { + Set questionIds = new HashSet<>(); + // key:collectionId => value:collectionElementDos + Map> collectionElementsMap = new HashMap<>(); + for (CollectionElementDo element : elements) { + Long collectionId = element.getCollectionId(); + List elementDoList; + if (collectionElementsMap.containsKey(collectionId)) { + elementDoList = collectionElementsMap.get(collectionId); + } else { + elementDoList = new ArrayList<>(); + collectionElementsMap.put(collectionId, elementDoList); + } + elementDoList.add(element); + questionIds.add(element.getElementId()); + } + + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(questionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + // questionId => QuestionSetDto + Map questionSetDtoMap = Optional.ofNullable(questionSetDtoList) + .orElse(new ArrayList<>(0)) + .stream() + .collect( + Collectors.toMap(QuestionSetDto::getQuestionId, Function.identity(), (v1, v2) -> v1) + ); + lessonCollectionMap.forEach((lessonId, collectionBusinessDoList) -> { + LessonSameQuestionsDto lessonSameQuestionsDto = lessonSameQuestionsDtoMap.get(lessonId); + List sameQuestions = + lessonSameQuestionsDto.getSameQuestions(); + for (LessonSameQuestionsDto.SameQuestionDto sameQuestion : sameQuestions) { + Long collectionId = sameQuestion.getCollectionId(); + List elementDoList = collectionElementsMap.get(collectionId); + List subQuestions = new ArrayList<>(); + for (CollectionElementDo collectionElementDo : elementDoList) { + Long questionId = collectionElementDo.getElementId(); + // 有可能题没有发布 + if (questionSetDtoMap.containsKey(questionId)) { + QuestionSetDto questionSet = questionSetDtoMap.get(questionId); + if (ElementRoleEnum.MAIN_QUESTION.getCode().equals(collectionElementDo.getElementRole())) { + LessonSameQuestionsDto.SameQuestionDto.QuestionSet mainQuestion = + new LessonSameQuestionsDto.SameQuestionDto.QuestionSet(); + sameQuestion.setMainQuestion(mainQuestion); + + mainQuestion.setLessonId(sameQuestion.getLessonId()); + mainQuestion.setQuestion(questionSet.getQuestion()); + mainQuestion.setStepQuestions(questionSet.getStepQuestions()); + mainQuestion.setElementRole(collectionElementDo.getElementRole()); + } else { + LessonSameQuestionsDto.SameQuestionDto.QuestionSet subQuestion = + new LessonSameQuestionsDto.SameQuestionDto.QuestionSet(); + subQuestions.add(subQuestion); + + subQuestion.setLessonId(sameQuestion.getLessonId()); + subQuestion.setQuestion(questionSet.getQuestion()); + subQuestion.setStepQuestions(questionSet.getStepQuestions()); + subQuestion.setElementRole(collectionElementDo.getElementRole()); + } + } + } + sameQuestion.setSubQuestions(subQuestions); + } + }); + } + } + + + return response; + } + + private List listSameQuestionsFromDb(Collection businessIds, Integer businessType) { + List response = new ArrayList<>(); + if (CollectionUtils.isEmpty(businessIds)) { + return response; + } + + Map businessSameQuestionsDtoMap = new HashMap<>(businessIds.size()); + for (Long businessId : businessIds) { + BusinessSameQuestionsDto businessSameQuestionsDto = new BusinessSameQuestionsDto(); + response.add(businessSameQuestionsDto); + + businessSameQuestionsDto.setBusinessId(businessId); + businessSameQuestionsDto.setBusinessType(businessType); + businessSameQuestionsDto.setSameQuestions(new ArrayList<>()); + + businessSameQuestionsDtoMap.put(businessId, businessSameQuestionsDto); + } + + List businesses = + collectionBusinessCoreService.listValidByBusinessIds(businessIds, BusinessTypeEnum.indexByType(businessType)); + if (CollectionUtils.isNotEmpty(businesses)) { + // lessonId => List + Map> collectionMap = new HashMap<>(); + Set collectionIds = new HashSet<>(businesses.size()); + for (CollectionBusinessDo business : businesses) { + Long collectionId = business.getCollectionId(); + Long businessId = business.getBusinessId(); + collectionIds.add(collectionId); + + List collectionBusinessDoList; + if (collectionMap.containsKey(businessId)) { + collectionBusinessDoList = collectionMap.get(businessId); + } else { + collectionBusinessDoList = new ArrayList<>(); + collectionMap.put(business.getBusinessId(), collectionBusinessDoList); + } + collectionBusinessDoList.add(business); + } + collectionMap.forEach((businessId, collectionBusinessDoList) -> { + BusinessSameQuestionsDto businessSameQuestionsDto = businessSameQuestionsDtoMap.get(businessId); + List sameQuestionDtoList = + businessSameQuestionsDto.getSameQuestions(); + + for (CollectionBusinessDo collectionBusinessDo : collectionBusinessDoList) { + BusinessSameQuestionsDto.SameQuestionDto sameQuestionDto = new BusinessSameQuestionsDto.SameQuestionDto(); + sameQuestionDtoList.add(sameQuestionDto); + + sameQuestionDto.setBusinessId(businessId); + sameQuestionDto.setCollectionId(collectionBusinessDo.getCollectionId()); + sameQuestionDto.setSortOrder(collectionBusinessDo.getSortOrder()); + } + }); + // 构建母子题 + List elements = collectionElementCoreService.listByCollectionIds( + collectionIds, + ElementTypeEnum.QUESTION_LIB + ); + if (CollectionUtils.isNotEmpty(elements)) { + Set questionIds = new HashSet<>(); + // key:collectionId => value:collectionElementDos + Map> collectionElementsMap = new HashMap<>(); + for (CollectionElementDo element : elements) { + Long collectionId = element.getCollectionId(); + List elementDoList; + if (collectionElementsMap.containsKey(collectionId)) { + elementDoList = collectionElementsMap.get(collectionId); + } else { + elementDoList = new ArrayList<>(); + collectionElementsMap.put(collectionId, elementDoList); + } + elementDoList.add(element); + questionIds.add(element.getElementId()); + } + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(questionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + // questionId => QuestionSetDto + Map questionSetDtoMap = Optional.ofNullable(questionSetDtoList) + .orElse(new ArrayList<>(0)) + .stream() + .collect( + Collectors.toMap(QuestionSetDto::getQuestionId, Function.identity(), (v1, v2) -> v1) + ); + collectionMap.forEach((businessId, collectionBusinessDoList) -> { + BusinessSameQuestionsDto businessSameQuestionsDto = businessSameQuestionsDtoMap.get(businessId); + List sameQuestions = + businessSameQuestionsDto.getSameQuestions(); + for (BusinessSameQuestionsDto.SameQuestionDto sameQuestion : sameQuestions) { + Long collectionId = sameQuestion.getCollectionId(); + List elementDoList = collectionElementsMap.get(collectionId); + List subQuestions = new ArrayList<>(); + for (CollectionElementDo collectionElementDo : elementDoList) { + Long questionId = collectionElementDo.getElementId(); + // 有可能题没有发布 + if (questionSetDtoMap.containsKey(questionId)) { + QuestionSetDto questionSet = questionSetDtoMap.get(questionId); + Integer elementRole = collectionElementDo.getElementRole(); + if (ElementRoleEnum.MAIN_QUESTION.getCode().equals(elementRole)) { + BusinessSameQuestionsDto.SameQuestionDto.QuestionSet mainQuestion = + new BusinessSameQuestionsDto.SameQuestionDto.QuestionSet(); + sameQuestion.setMainQuestion(mainQuestion); + + mainQuestion.setBusinessId(sameQuestion.getBusinessId()); + mainQuestion.setQuestion(questionSet.getQuestion()); + mainQuestion.setElementRole(elementRole); + mainQuestion.setStepQuestions(questionSet.getStepQuestions()); + } else { + BusinessSameQuestionsDto.SameQuestionDto.QuestionSet subQuestion = + new BusinessSameQuestionsDto.SameQuestionDto.QuestionSet(); + subQuestions.add(subQuestion); + + subQuestion.setBusinessId(sameQuestion.getBusinessId()); + subQuestion.setQuestion(questionSet.getQuestion()); + subQuestion.setElementRole(elementRole); + subQuestion.setStepQuestions(questionSet.getStepQuestions()); + } + } + } + sameQuestion.setSubQuestions(subQuestions); + } + }); + } + } + + return response; + } + + private List listSameQuestionsFromCache(Collection businessIds, Integer businessType) { + List response = new ArrayList<>(); + if (CollectionUtils.isEmpty(businessIds)) { + return response; + } + + // businessId => BusinessSameQuestionsDto + Map businessSameQuestionsDtoMap = new HashMap<>(businessIds.size()); + for (Long businessId : businessIds) { + BusinessSameQuestionsDto businessSameQuestionsDto = new BusinessSameQuestionsDto(); + response.add(businessSameQuestionsDto); + + businessSameQuestionsDto.setBusinessId(businessId); + businessSameQuestionsDto.setBusinessType(businessType); + businessSameQuestionsDto.setSameQuestions(new ArrayList<>()); + + businessSameQuestionsDtoMap.put(businessId, businessSameQuestionsDto); + } + // 获取collection_business + // lessonId => Set + Map> collectionBusinessesMap = collectionCacheService.listBusinessesFromCache( + businessIds, + BusinessTypeEnum.indexByType(businessType) + ); + List businesses = new ArrayList<>(); + Set noneCacheBusinessIds = new HashSet<>(); + for (Long businessId : businessIds) { + Set collectionBusinessDoSet = collectionBusinessesMap.get(businessId); + if (Objects.isNull(collectionBusinessDoSet)) { + noneCacheBusinessIds.add(businessId); + } else if (!collectionBusinessDoSet.isEmpty()) { + businesses.addAll(collectionBusinessDoSet); + } + } + // 缓存collection_business + if (!noneCacheBusinessIds.isEmpty()) { + List businessesFromDb = collectionBusinessCoreService.listValidByBusinessIds( + noneCacheBusinessIds, + BusinessTypeEnum.indexByType(businessType) + ); + Map> noneCacheBusinessesMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(businessesFromDb)) { + businesses.addAll(businessesFromDb); + noneCacheBusinessesMap = businessesFromDb + .stream() + .collect(Collectors.groupingBy(CollectionBusinessDo::getBusinessId)); + } + for (Long noneCacheBusinessId : noneCacheBusinessIds) { + noneCacheBusinessesMap.putIfAbsent(noneCacheBusinessId, new ArrayList<>(0)); + } + if (!noneCacheBusinessesMap.isEmpty()) { + collectionCacheService.putBusinessesToCache(noneCacheBusinessesMap, BusinessTypeEnum.indexByType(businessType)); + } + } + // 构建相似题组 + if (CollectionUtils.isNotEmpty(businesses)) { + // lessonId => List + Map> collectionBusinessMap = new HashMap<>(); + Set collectionIds = new HashSet<>(businesses.size()); + for (CollectionBusinessDo business : businesses) { + Long collectionId = business.getCollectionId(); + Long businessId = business.getBusinessId(); + collectionIds.add(collectionId); + + List collectionBusinessDoList; + if (collectionBusinessMap.containsKey(businessId)) { + collectionBusinessDoList = collectionBusinessMap.get(businessId); + } else { + collectionBusinessDoList = new ArrayList<>(); + collectionBusinessMap.put(business.getBusinessId(), collectionBusinessDoList); + } + collectionBusinessDoList.add(business); + } + collectionBusinessMap.forEach((businessId, collectionBusinessDoList) -> { + BusinessSameQuestionsDto businessSameQuestionsDto = businessSameQuestionsDtoMap.get(businessId); + List sameQuestionDtoList = new ArrayList<>(); + businessSameQuestionsDto.setSameQuestions(sameQuestionDtoList); + + for (CollectionBusinessDo collectionBusinessDo : collectionBusinessDoList) { + BusinessSameQuestionsDto.SameQuestionDto sameQuestionDto = new BusinessSameQuestionsDto.SameQuestionDto(); + sameQuestionDtoList.add(sameQuestionDto); + + sameQuestionDto.setBusinessId(businessId); + sameQuestionDto.setCollectionId(collectionBusinessDo.getCollectionId()); + sameQuestionDto.setSortOrder(collectionBusinessDo.getSortOrder()); + } + }); + + // 获取collection_element + List elements = new ArrayList<>(); + Map> cacheElementsMap = collectionCacheService.listElementsFromCache(collectionIds); + Set noneCacheCollectionIds = new HashSet<>(); + for (Long collectionId : collectionIds) { + Set cacheElements = cacheElementsMap.get(collectionId); + if (Objects.isNull(cacheElements)) { + noneCacheCollectionIds.add(collectionId); + } else if (!cacheElements.isEmpty()){ + elements.addAll(cacheElements); + } + } + // 缓存collectionElement + if (!noneCacheCollectionIds.isEmpty()) { + List elementsFromDb = collectionElementCoreService.listByCollectionIds( + noneCacheCollectionIds, + ElementTypeEnum.QUESTION_LIB + ); + Map> noneCacheElementMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(elementsFromDb)) { + elements.addAll(elementsFromDb); + noneCacheElementMap = elementsFromDb + .stream() + .collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)); + } + for (Long noneCacheCollectionId : noneCacheCollectionIds) { + if (!noneCacheElementMap.containsKey(noneCacheCollectionId)) { + noneCacheElementMap.put(noneCacheCollectionId, new ArrayList<>(0)); + } + } + if (!noneCacheElementMap.isEmpty()) { + collectionCacheService.putElementToCache(noneCacheElementMap); + } + } + + if (CollectionUtils.isNotEmpty(elements)) { + Set questionIds = new HashSet<>(); + // key:collectionId => value:collectionElementDos + Map> collectionElementsMap = new HashMap<>(); + for (CollectionElementDo element : elements) { + Long collectionId = element.getCollectionId(); + List elementDoList; + if (collectionElementsMap.containsKey(collectionId)) { + elementDoList = collectionElementsMap.get(collectionId); + } else { + elementDoList = new ArrayList<>(); + collectionElementsMap.put(collectionId, elementDoList); + } + elementDoList.add(element); + questionIds.add(element.getElementId()); + } + SearchQuestionBo query = SearchQuestionBo.builder() + .ids(questionIds) + .queryBusinessType(QuestionQueryBusinessTypeEnum.MANAGER_LIST.getType()) + .expectGroups(true) + .expectStep(true) + .expectDefaultGameSkin(true) + .expectQuestionTypeCategory(true) + .build(); + List questionSetDtoList = questionManager.listQuestionSetByQuestionIds(query); + // questionId => QuestionSetDto + Map questionSetDtoMap = Optional.ofNullable(questionSetDtoList) + .orElse(new ArrayList<>(0)) + .stream() + .collect( + Collectors.toMap(QuestionSetDto::getQuestionId, Function.identity(), (v1, v2) -> v1) + ); + collectionBusinessMap.forEach((businessId, collectionBusinessDoList) -> { + BusinessSameQuestionsDto businessSameQuestionsDto = businessSameQuestionsDtoMap.get(businessId); + List sameQuestions = + businessSameQuestionsDto.getSameQuestions(); + for (BusinessSameQuestionsDto.SameQuestionDto sameQuestion : sameQuestions) { + Long collectionId = sameQuestion.getCollectionId(); + List elementDoList = collectionElementsMap.get(collectionId); + List subQuestions = new ArrayList<>(); + for (CollectionElementDo collectionElementDo : elementDoList) { + Long questionId = collectionElementDo.getElementId(); + // 有可能题没有发布 + if (questionSetDtoMap.containsKey(questionId)) { + QuestionSetDto questionSet = questionSetDtoMap.get(questionId); + if (ElementRoleEnum.MAIN_QUESTION.getCode().equals(collectionElementDo.getElementRole())) { + BusinessSameQuestionsDto.SameQuestionDto.QuestionSet mainQuestion = + new BusinessSameQuestionsDto.SameQuestionDto.QuestionSet(); + sameQuestion.setMainQuestion(mainQuestion); + + mainQuestion.setBusinessId(sameQuestion.getBusinessId()); + mainQuestion.setQuestion(questionSet.getQuestion()); + mainQuestion.setStepQuestions(questionSet.getStepQuestions()); + mainQuestion.setElementRole(collectionElementDo.getElementRole()); + } else { + BusinessSameQuestionsDto.SameQuestionDto.QuestionSet subQuestion = + new BusinessSameQuestionsDto.SameQuestionDto.QuestionSet(); + subQuestions.add(subQuestion); + + subQuestion.setBusinessId(sameQuestion.getBusinessId()); + subQuestion.setQuestion(questionSet.getQuestion()); + subQuestion.setStepQuestions(questionSet.getStepQuestions()); + subQuestion.setElementRole(collectionElementDo.getElementRole()); + } + } + } + sameQuestion.setSubQuestions(subQuestions); + } + }); + } + } + return response; + } + + private void buildRequireState(List sameQuestionDtos) { + if (CollectionUtils.isEmpty(sameQuestionDtos)) { + return; + } + Set questionSets = sameQuestionDtos.stream() + .map(LessonSameQuestionsDto.SameQuestionDto::getMainQuestion) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + questionSets.addAll( + sameQuestionDtos.stream() + .flatMap(e -> Optional.ofNullable(e.getSubQuestions()).orElse(new ArrayList<>(0)).stream()) + .filter(Objects::nonNull) + .collect(Collectors.toSet())); + + List questionDtos = + questionSets.stream().map(LessonSameQuestionsDto.SameQuestionDto.QuestionSet::getQuestion) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + questionDtos.addAll( + questionSets.stream() + .flatMap(e -> Optional.ofNullable(e.getStepQuestions()).orElse(new ArrayList<>(0)).stream()) + .filter(Objects::nonNull) + .collect(Collectors.toList()) + ); + + Set questionIds = questionDtos.stream().map(QuestionDto::getQuestionId).collect(Collectors.toSet()); + List questionRequireDoList = questionProduceBizService.listRequiresByQuestionIds(questionIds); + Map stateMap = questionRequireDoList.stream() + .collect(Collectors.toMap(QuestionRequireDo::getQuestionId, QuestionRequireDo::getRequireState, (v1, v2) -> v1)); + for (QuestionDto questionDto : questionDtos) { + Long questionId = questionDto.getQuestionId(); + if (stateMap.containsKey(questionId)) { + questionDto.setRequireState(stateMap.get(questionId)); + // 是否能被审的题判断:题库管理中未废弃的状态 + questionDto.setIsReviewable( + !QuestionRequireStateEnum.REMOVED.getState().equals(stateMap.get(questionId)) ? + YesOrNoEnum.YES.getValue() : YesOrNoEnum.NO.getValue()); + } else { + questionDto.setIsReviewable(YesOrNoEnum.YES.getValue()); + } + } + } + + private void buildSubRequireState(List result) { + if (CollectionUtils.isEmpty(result)) { + return; + } + List questionDtos = + result.stream().flatMap(e -> Optional.ofNullable(e.getStepQuestions()).orElse(new ArrayList<>(0)).stream()) + .filter(Objects::nonNull) + .collect(Collectors.toList()); + questionDtos.addAll( + result.stream().map(QuestionSetDto::getQuestion).filter(Objects::nonNull).collect(Collectors.toList())); + Set questionIds = questionDtos.stream().map(QuestionDto::getQuestionId).collect(Collectors.toSet()); + List questionRequireDoList = questionProduceBizService.listRequiresByQuestionIds(questionIds); + Map stateMap = questionRequireDoList.stream() + .collect(Collectors.toMap(QuestionRequireDo::getQuestionId, QuestionRequireDo::getRequireState, (v1, v2) -> v1)); + for (QuestionDto questionDto : questionDtos) { + Long questionId = questionDto.getQuestionId(); + if (stateMap.containsKey(questionId)) { + questionDto.setRequireState(stateMap.get(questionId)); + // 是否能被审的题判断:题库管理中未废弃的状态 + questionDto.setIsReviewable( + !QuestionRequireStateEnum.REMOVED.getState().equals(stateMap.get(questionId)) ? + YesOrNoEnum.YES.getValue() : YesOrNoEnum.NO.getValue()); + } else { + questionDto.setIsReviewable(YesOrNoEnum.YES.getValue()); + } + } + } + + private void buildSameQuestionDto(List result, + List questionSetDtos, + Long lessonId, + Map elementDoMap, + Map collectionSortOrderMap) { + for (QuestionSetDto questionSetDto : questionSetDtos) { + LessonSameQuestionsDto.SameQuestionDto sameQuestionDto = new LessonSameQuestionsDto.SameQuestionDto(); + result.add(sameQuestionDto); + + CollectionElementDo elementDo = elementDoMap.get(questionSetDto.getQuestionId()); + sameQuestionDto.setLessonId(lessonId); + sameQuestionDto.setCollectionId(elementDo.getCollectionId()); + sameQuestionDto.setSortOrder(collectionSortOrderMap.getOrDefault(sameQuestionDto.getCollectionId(), 1)); + LessonSameQuestionsDto.SameQuestionDto.QuestionSet questionSet = + new LessonSameQuestionsDto.SameQuestionDto.QuestionSet(); + questionSet.setLessonId(lessonId); + questionSet.setQuestion(questionSetDto.getQuestion()); + questionSet.setElementRole(elementDo.getElementRole()); + questionSet.setStepQuestions(questionSetDto.getStepQuestions()); + sameQuestionDto.setMainQuestion(questionSet); + } + } + + /** + * 获取母题集合元素 + *

当前业务中,一个集合有且仅有一个母题

+ * + * @param mainQuestionId 母题ID + * @return 母题集合元素 + */ + private CollectionElementDo getMainElementByQuestionId(Long mainQuestionId) { + List elements = collectionElementCoreService.listValidByElementIds( + Collections.singletonList(mainQuestionId), + ElementTypeEnum.QUESTION_LIB, + ElementRoleEnum.MAIN_QUESTION + ); + if (CollectionUtils.isEmpty(elements)) { + return null; + } else { + return elements.get(0); + } + } + + private List listCollectionBusinessIdAndTypeCache(Collection businessIds, + BusinessTypeEnum bsType) { + Map> businessesMap = collectionCacheService.listBusinessesFromCache( + businessIds, + bsType + ); + List businesses = new ArrayList<>(); + Set noneCacheBusinessIds = new HashSet<>(); + for (Long businessId : businessIds) { + Set collectionBusinessDoSet = businessesMap.get(businessId); + if (Objects.isNull(collectionBusinessDoSet)) { + noneCacheBusinessIds.add(businessId); + } else if (!collectionBusinessDoSet.isEmpty()) { + businesses.addAll(collectionBusinessDoSet); + } + } + // 缓存collection_business + if (!noneCacheBusinessIds.isEmpty()) { + List businessesFromDb = collectionBusinessCoreService.listValidByBusinessIds(noneCacheBusinessIds, bsType); + Map> noneCacheBusinessesMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(businessesFromDb)) { + businesses.addAll(businessesFromDb); + noneCacheBusinessesMap = businessesFromDb + .stream() + .collect(Collectors.groupingBy(CollectionBusinessDo::getBusinessId)); + } + for (Long noneCacheBusinessId : noneCacheBusinessIds) { + noneCacheBusinessesMap.putIfAbsent(noneCacheBusinessId, new ArrayList<>(0)); + } + if (!noneCacheBusinessesMap.isEmpty()) { + collectionCacheService.putBusinessesToCache(noneCacheBusinessesMap, bsType); + } + } + return businesses; + } + + private List listCollectionBusinessByLessonIdsDb(Collection lessonIds) { + return collectionBusinessCoreService.listValidByBusinessIds( + lessonIds, + BusinessTypeEnum.LESSON + ); + } + + private List listCollectionElementByCollectionIdsCache(Collection collectionIds) { + List elements = new ArrayList<>(); + Map> cacheElementsMap = collectionCacheService.listElementsFromCache(collectionIds); + Set noneCacheCollectionIds = new HashSet<>(); + for (Long collectionId : collectionIds) { + Set cacheElements = cacheElementsMap.get(collectionId); + if (Objects.isNull(cacheElements)) { + noneCacheCollectionIds.add(collectionId); + } else if (!cacheElements.isEmpty()){ + elements.addAll(cacheElements); + } + } + // 缓存collectionElement + if (!noneCacheCollectionIds.isEmpty()) { + List elementsFromDb = listCollectionElementByCollectionIdsDb(noneCacheCollectionIds); + Map> noneCacheElementMap = new HashMap<>(); + if (CollectionUtils.isNotEmpty(elementsFromDb)) { + elements.addAll(elementsFromDb); + noneCacheElementMap = elementsFromDb + .stream() + .collect(Collectors.groupingBy(CollectionElementDo::getCollectionId)); + } + for (Long noneCacheCollectionId : noneCacheCollectionIds) { + if (!noneCacheElementMap.containsKey(noneCacheCollectionId)) { + noneCacheElementMap.put(noneCacheCollectionId, new ArrayList<>(0)); + } + } + if (!noneCacheElementMap.isEmpty()) { + collectionCacheService.putElementToCache(noneCacheElementMap); + } + } + return elements; + } + + private List listCollectionElementByCollectionIdsDb(Collection collectionIds) { + return collectionElementCoreService.listByCollectionIds( + collectionIds, + ElementTypeEnum.QUESTION_LIB); + } + + @Override + public DataSourceRouterWrapper split(String methodName, Object[] arguments) { + List methodNames = Lists.newArrayList("listQuestionByIds", "listQuestionEnhanceByIds"); + if ("listQuestionIdByScenes".equals(methodName) && ArrayUtils.isNotEmpty(arguments)) { + QuestionIdsReq questionIdsReq = (QuestionIdsReq) arguments[0]; + ValidatorUtils.nullCheck(questionIdsReq); + ValidatorUtils.checkIntegerParam(questionIdsReq.getBusinessScenes(), BUSINESS_SCENES_NOT_NULL); + ValidatorUtils.checkList(1 != questionIdsReq.getBusinessScenes(), BUSINESS_SCENES_NOT_SUPPORT); + return DataSourceRouterWrapper.defaultA(); + } else if (methodNames.contains(methodName) && ArrayUtils.isNotEmpty(arguments)) { + QuestionsReq questionsReq = (QuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(questionsReq); + List questionIds = questionsReq.getQuestionIds(); + ValidatorUtils.checkList( + CollectionUtils.isEmpty(questionIds) || questionIds.size() > HUNDRED, SIZE_ONE_HUNDRED); + return DataSourceRouterWrapper.splitIds(questionIds); + } else if ("listKnowledgeLabelByQuestionKeys".equals(methodName) && ArrayUtils.isNotEmpty(arguments)) { + KnowledgeLabelReq knowledgeLabelReq = (KnowledgeLabelReq) arguments[0]; + ValidatorUtils.nullCheck(knowledgeLabelReq); + Long courseId = knowledgeLabelReq.getCourseId(); + ValidatorUtils.checkLongParam(courseId, COURSE_ID_NOT_NULL); + Set questionKeys = knowledgeLabelReq.getQuestionKeys(); + ValidatorUtils.checkList( + CollectionUtils.isEmpty(questionKeys) || questionKeys.size() > HUNDRED, SIZE_ONE_HUNDRED); + return DataSourceRouterWrapper.id(courseId); + } else if ("getQuestionUpdateTimeByKey".equals(methodName) && ArrayUtils.isNotEmpty(arguments)) { + QuestionUpdateTimeReq req = (QuestionUpdateTimeReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.nullCheck(req.getQuestionKey()); + ValidatorUtils.nullCheck(req.getEnv()); + return DataSourceRouterWrapper.type(req.getEnv()); + } else if ("listQuestionByKeys".equals(methodName) || "listSimpleByQuestionKeys".equals(methodName)) { + ListQuestionByKeysReq req = (ListQuestionByKeysReq) arguments[0]; + ValidatorUtils.nullCheck(req); + Set questionKeys = req.getQuestionKeys(); + ValidatorUtils.checkList( + CollectionUtils.isEmpty(questionKeys) || questionKeys.size() > HUNDRED, + SIZE_ONE_HUNDRED + ); + return CourseCenterUtil.isJyById(req.getCourseOrLessonId()) ? + DataSourceRouterWrapper.teaching() : + DataSourceRouterWrapper.defaultA(); + } else if ("listLessonSameQuestions".equals(methodName)) { + ListLessonSameQuestionsReq req = (ListLessonSameQuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.nullCheck(req.getLessonIds()); + List lessonIds = req.getLessonIds(); + for (Long lessonId : lessonIds) { + String message = MessageSourceUtils.getMixedMessage("error_local.service.question_2"); + ValidatorUtils.checkPositiveNumber(lessonId, message + lessonId); + } + if (lessonIds.size() > 5) { + throw BusinessException.paramException( + MessageSourceUtils.getMixedMessage("error_local.service.question_3") + ); + } + return DataSourceRouterWrapper.splitIds(lessonIds); + } else if ("existLessonSameQuestions".equals(methodName)) { + ListLessonSameQuestionsReq req = (ListLessonSameQuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.nullCheck(req.getLessonIds()); + List lessonIds = req.getLessonIds(); + for (Long lessonId : lessonIds) { + ValidatorUtils.nullCheck(lessonId); + String message = MessageSourceUtils.getMixedMessage("error_local.service.question_2"); + ValidatorUtils.checkPositiveNumber(lessonId, message + lessonId); + } + if (lessonIds.size() > 100) { + throw BusinessException.paramException( + MessageSourceUtils.getMixedMessage("error_local.service.question_4") + ); + } + DataSourceRouterWrapper dataSourceRouterWrapper = DataSourceRouterWrapper.splitIds(lessonIds); + if (dataSourceRouterWrapper.getRouterEnum() == DataBaseTypeEnum.BOTH) { + // 重新设置回正式参数 + req.setLessonIds((List) dataSourceRouterWrapper.getArguments()[0]); + dataSourceRouterWrapper.setArguments(new Object[]{req}); + + ListLessonSameQuestionsReq listLessonSameQuestionsReq = new ListLessonSameQuestionsReq(); + BeanUtils.copyProperties(req, listLessonSameQuestionsReq); + listLessonSameQuestionsReq + .setLessonIds((List) dataSourceRouterWrapper.getTeachingArguments()[0]); + dataSourceRouterWrapper.setTeachingArguments(new Object[]{listLessonSameQuestionsReq}); + } + return dataSourceRouterWrapper; + } else if ("listTestSameQuestions".equals(methodName)) { + ListTestSameQuestionsReq req = (ListTestSameQuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.nullCheck(req.getTestIds()); + ValidatorUtils.checkList(CollectionUtils.isEmpty(req.getTestIds()) || req.getTestIds().size() > FIVE, + SIZE_FIVE); + List testIds = req.getTestIds(); + for (Long testId : testIds) { + String message = MessageSourceUtils.getMixedMessage("error_local.service.question_5"); + ValidatorUtils.checkPositiveNumber(testId, message + testId); + } + return DataSourceRouterWrapper.splitIds(testIds); + } else if ("listSameQuestionsByLessonId".equals(methodName)) { + ListSameQuestionsReq req = (ListSameQuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(req); + Long lessonId = req.getLessonId(); + ValidatorUtils.nullCheck(lessonId); + String message = MessageSourceUtils.getMixedMessage("error_local.service.question_2"); + ValidatorUtils.checkPositiveNumber(lessonId, message + lessonId); + DataSourceRouterWrapper id = DataSourceRouterWrapper.id(lessonId); + if (DataBaseTypeEnum.PROD.equals(id.getRouterEnum())) { + throw BusinessException.exception( + MessageSourceUtils.getMixedMessage("error_local.service.question_1") + ); + } + return id; + } else if ("listSubQuestions".equals(methodName)) { + ListSubQuestionsReq req = (ListSubQuestionsReq) arguments[0]; + ValidatorUtils.nullCheck(req); + String lessonKey = req.getLessonKey(); + ValidatorUtils.nullCheck(lessonKey); + ValidatorUtils.nullCheck(req.getQuestionKey()); + DataSourceRouterWrapper key = DataSourceRouterWrapper.key(lessonKey); + if (DataBaseTypeEnum.PROD.equals(key.getRouterEnum())) { + throw BusinessException.exception( + MessageSourceUtils.getMixedMessage("error_local.service.question_1") + ); + } + return key; + } else if ("getQuestionJsonByKey".equals(methodName)) { + GetQuestionJsonReq req = (GetQuestionJsonReq) arguments[0]; + ValidatorUtils.nullCheck(req); + String lessonKey = req.getLessonKey(); + ValidatorUtils.nullCheck(lessonKey); + ValidatorUtils.nullCheck(req.getQuestionKey()); + DataSourceRouterWrapper key = DataSourceRouterWrapper.key(lessonKey); + if (DataBaseTypeEnum.PROD.equals(key.getRouterEnum())) { + throw BusinessException.exception( + MessageSourceUtils.getMixedMessage("error_local.service.question_1") + ); + } + return key; + } else if ("listMetaQuestionsByKeys".equals(methodName)) { + ListMetaQuestionsByKeysReq req = (ListMetaQuestionsByKeysReq) arguments[0]; + ValidatorUtils.nullCheck(req); + String lessonKey = req.getLessonKey(); + ValidatorUtils.nullCheck(lessonKey); + ValidatorUtils.checkList(CollectionUtils.isEmpty(req.getQuestionKeys()) || + req.getQuestionKeys().size() > FIFTY, SIZE_FIFTY); + return DataSourceRouterWrapper.key(lessonKey); + } else if ("listStandardQuestion".equals(methodName)) { + ListStandardQuestionReq req = (ListStandardQuestionReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.checkForValidator( + !(Objects.isNull(req.getCourseId()) && Objects.isNull(req.getEnv())), + MessageSourceUtils.getMixedMessage("error_local.service.question_6") + ); + ValidatorUtils.checkForValidator( + !(Objects.nonNull(req.getCourseId()) && Objects.nonNull(req.getEnv())), + MessageSourceUtils.getMixedMessage("error_local.service.question_7") + ); + List reqKeys = + Optional.ofNullable(req.getKeys()).orElse(new ArrayList<>(0)); + ValidatorUtils.checkList(reqKeys.size() > HUNDRED, SIZE_ONE_HUNDRED); + if (Objects.nonNull(req.getCourseId())) { + return DataSourceRouterWrapper.id(req.getCourseId()); + } else { + return DataSourceRouterWrapper.type(req.getEnv()); + } + } + else if ("listReport".equals(methodName)) { + ListQuestionReportReq req = (ListQuestionReportReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.checkForValidator(Objects.nonNull(req.getEnv()), + MessageSourceUtils.getMixedMessage("error_local.service.question_6") + ); + List reqKeys = + Optional.ofNullable(req.getKeys()).orElse(new ArrayList<>(0)); + ValidatorUtils.checkList(CollectionUtils.isEmpty(reqKeys) || reqKeys.size() > FIFTY, SIZE_FIFTY); + return DataSourceRouterWrapper.type(req.getEnv()); + } + else if ("listPrintableQuestions".equals(methodName)) { + PrintableQuestionReq req = (PrintableQuestionReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.checkForValidator(Objects.nonNull(req.getEnv()), + MessageSourceUtils.getMixedMessage("error_local.service.question_6") + ); + List reqKeys = + Optional.ofNullable(req.getKeys()).orElse(new ArrayList<>(0)); + ValidatorUtils.checkList(CollectionUtils.isEmpty(reqKeys) || reqKeys.size() > HUNDRED, SIZE_ONE_HUNDRED); + reqKeys.forEach(e -> { + ValidatorUtils.nullCheck(e); + ValidatorUtils.checkStringParam(e.getQuestionKey(), + MessageSourceUtils.getMixedMessage("error_local.biz.service.game_tool_content_1")); + }); + return DataSourceRouterWrapper.type(req.getEnv()); + } else if ("listVerifyQuestion".equals(methodName)) { + tenantUtils.getTenantId(); + ListVerifyQuestionReq req = (ListVerifyQuestionReq) arguments[0]; + ValidatorUtils.nullCheck(req); + ValidatorUtils.checkForValidator(Objects.nonNull(req.getEnv()), + MessageSourceUtils.getMixedMessage("error_local.service.question_6") + ); + Set questionKeys = req.getQuestionKeys(); + ValidatorUtils.checkList(CollectionUtils.isEmpty(questionKeys) || questionKeys.size() > HUNDRED, SIZE_ONE_HUNDRED); + return DataSourceRouterWrapper.type(req.getEnv()); + } else { + throw BusinessException.exception( + MessageSourceUtils.getMixedMessage("error_local.not_support_split_method")); + } + } +} --- /dev/null +++ b/api/api-server/src/test/java/cn/jojo/cc/server/service/impl/QuestionServiceImplSegmentsTest.java @@ -0,0 +1,118 @@ +package cn.jojo.cc.server.service.impl; + +import cn.jojo.cc.biz.service.english.segment.EnglishSegmentDeliveryBizService; +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import cn.jojo.cc.common.dto.question.library.StandardQuestionDto; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anySet; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 描述:题目下发装配划词的接线测试(Task 23)。 + * + *

装配语义在 {@code EnglishSegmentDeliveryBizServiceTest} 里锁;本类只管接线这一层: + * 交给装配器的 key 必须是 DTO 的 questionKey 原值(做任何 JY_ 前缀加工都会静默查空, + * 而查空在功能上表现为“划词永远不出现”,编译与测试都不会报错),命中的题填上、未命中的保持 null, + * 以及装配器抛异常时题目本体照常返回。

+ */ +@ExtendWith(MockitoExtension.class) +class QuestionServiceImplSegmentsTest { + + @InjectMocks + private QuestionServiceImpl questionService; + + @Mock + private EnglishSegmentDeliveryBizService englishSegmentDeliveryBizService; + + private StandardQuestionDto question(String key) { + StandardQuestionDto dto = new StandardQuestionDto(); + dto.setQuestionKey(key); + return dto; + } + + private void fill(List questions) { + ReflectionTestUtils.invokeMethod(questionService, "fillSegments", questions, Collections.emptyMap()); + } + + @Test + @SuppressWarnings("unchecked") + void keysArePassedThroughUnchanged() { + // 断言的是“原值传递”:装配链路里 cc_question_segment.question_key 与 DTO 的 key 同源, + // 任何补前缀/去前缀的“顺手处理”都会让它查空。 + when(englishSegmentDeliveryBizService.resolveByQuestionKeys(anySet(), any())).thenReturn(Collections.emptyMap()); + List questions = Arrays.asList(question("JY_q1"), question("q2")); + + fill(questions); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Set.class); + verify(englishSegmentDeliveryBizService).resolveByQuestionKeys(captor.capture(), any()); + assertEquals(2, captor.getValue().size()); + // 原值传递:JY_ 前缀不得被“顺手”规整,否则映射到分词表就查空。 + assertTrue(captor.getValue().contains("JY_q1")); + assertTrue(captor.getValue().contains("q2")); + } + + @Test + void matchedQuestionsFilledOthersStayNull() { + StandardQuestionDto hit = question("q1"); + StandardQuestionDto miss = question("q2"); + StandardQuestionDto blankKey = question(" "); + EnglishSegmentDto segments = EnglishSegmentDto.builder() + .matches(List.of(EnglishSegmentDto.SegmentMatch.builder() + .text("good").type(EnglishSegmentDto.TYPE_WORD).wordId(7L).build())) + .build(); + when(englishSegmentDeliveryBizService.resolveByQuestionKeys(anySet(), any())) + .thenReturn(Map.of("q1", segments)); + + fill(Arrays.asList(hit, miss, blankKey)); + + assertNotNull(hit.getSegments()); + assertEquals(7L, hit.getSegments().getMatches().get(0).getWordId()); + // 无分词行必须是 null,不能是空序列:两者在消费端是不同表现。 + assertNull(miss.getSegments()); + assertNull(blankKey.getSegments()); + } + + @Test + void resolverFailureLeavesQuestionsDeliverableWithoutSegments() { + StandardQuestionDto question = question("q1"); + question.setQuestionType(30002); + when(englishSegmentDeliveryBizService.resolveByQuestionKeys(anySet(), any())) + .thenThrow(new IllegalStateException("segment table down")); + + fill(List.of(question)); + + // 划词是衍生数据:读库异常既不冒泡也不污染题目本体。 + assertNull(question.getSegments()); + assertEquals(Integer.valueOf(30002), question.getQuestionType()); + } + + @Test + void emptyListSkipsAssemblerEntirely() { + // 不测 null 入参:那是生产不行使的代码路径(调用方传的是 stream 结果), + // 而 ReflectionTestUtils 传单个 null 参数时会与“无参调用”歧义,测不到真东西。 + fill(Collections.emptyList()); + + verify(englishSegmentDeliveryBizService, never()).resolveByQuestionKeys(any()); + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/model/bo/english/SegmentSnapshot.java @@ -0,0 +1,94 @@ +package cn.jojo.cc.biz.model.bo.english; + +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * 描述:分词结果落库的精简快照——只存划词块的文本与类型,不存词条 ID。 + * + *

为什么不存 ID:ID 是「当前词典」的属性,不是「这道题」的属性。一旦把它写进快照, + * 就要为它背两份高风险逻辑:① 补录出新词后,按词 × 按题回刷所有历史快照(否则题里的占位永久点不动); + * ② 题目跨库发布时,把快照里的每个 ID 重映射成正式库 ID(占位值参与映射就会算出坏 ID)。 + * 改为下发时按 {@code text} 现算(见 {@code SegmentMatchIdResolver})之后,划词可点击性自然跟随 + * 词典当前状态,上述两块逻辑整块消失,存量数据也不需要迁移。

+ * + *

序列语义与下发结构完全一致(生产项顺序 × 项内文档顺序、不去重、不含位置),仅少了 ID。 + * 读取侧必须经 {@code toDto()} + 读时解析后才可下发,不得把 {@code toDto()} 的结果直接给消费端。

+ */ +@Data +public class SegmentSnapshot implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 划词块序列,与下发结构的 matches 一一对应 */ + private List matches; + + private String sourcePayloadHash; + private Long sourceFormDataId; + private Long sourceContentConfigId; + private String sourceOutputPayloadHash; + + /** + * 单个划词块:只保留还原它所必需的文本与类型。 + */ + @Data + public static class Item implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 命中文本,归一化小写(与读时解析的查询键同源) */ + private String text; + + /** 块类型,取 {@code EnglishSegmentDto.TYPE_WORD / TYPE_PHRASE} */ + private String type; + } + + /** + * 下发结构 -> 落库快照:剥掉全部 ID 字段,null 项跳过。 + */ + public static SegmentSnapshot from(EnglishSegmentDto dto) { + SegmentSnapshot snapshot = new SegmentSnapshot(); + if (dto == null || dto.getMatches() == null) { + snapshot.setMatches(Collections.emptyList()); + return snapshot; + } + List items = new ArrayList<>(dto.getMatches().size()); + for (EnglishSegmentDto.SegmentMatch match : dto.getMatches()) { + if (match == null) { + continue; + } + Item item = new Item(); + item.setText(match.getText()); + item.setType(match.getType()); + items.add(item); + } + snapshot.setMatches(items); + return snapshot; + } + + /** + * 落库快照 -> 下发结构:ID 一律留空,由读时解析按当前词典填充。 + */ + public EnglishSegmentDto toDto() { + EnglishSegmentDto dto = new EnglishSegmentDto(); + List result = new ArrayList<>(); + if (matches != null) { + for (Item item : matches) { + if (item == null) { + continue; + } + result.add(EnglishSegmentDto.SegmentMatch.builder() + .text(item.getText()) + .type(item.getType()) + .build()); + } + } + dto.setMatches(result); + return dto; + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/model/cache/FormAndDataCacheBo.java @@ -0,0 +1,71 @@ +package cn.jojo.cc.biz.model.cache; + +import com.alibaba.fastjson.JSON; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +import java.io.Serializable; +import io.swagger.v3.oas.annotations.media.Schema; + +/** + * 描述: + * + * @author: ZHR + * @data: 2024-03-21 + */ +@Data +@AllArgsConstructor +@NoArgsConstructor +@Schema(title = "描述:", description = "form and data cache业务对象") +public class FormAndDataCacheBo implements Serializable { + + /** + * 业务ID + */ + @Schema(title = "businessID", description = "businessID") + private Long businessId; + + /** + * 业务类型 + */ + @Schema(title = "business类型", description = "business类型") + private Integer businessType; + + /** + * 业务类型二级分类 + */ + @Schema(title = "business type classify", description = "business type classify") + private Integer businessTypeClassify; + + /** + * 数据输出类型 + */ + @Schema(title = "output data类型", description = "output data类型") + private Integer outputDataType; + + /** + * 数据输出json 地址 + */ + @Schema(title = "out data json", description = "out data json") + private String outDataJson; + + /** + * JSON对象 + */ + @Schema(title = "out data json obj", description = "out data json obj") + private JSON outDataJsonObj; + + /** 与outDataJson从同一OutputData行读取,旧缓存缺失即不可采信。 */ + private Long segmentSourceFormDataId; + private String segmentSourcePayloadHash; + + /** 保留已有六参调用兼容。 */ + public FormAndDataCacheBo(Long businessId, Integer businessType, Integer businessTypeClassify, + Integer outputDataType, String outDataJson, JSON outDataJsonObj) { + this.businessId = businessId; this.businessType = businessType; + this.businessTypeClassify = businessTypeClassify; this.outputDataType = outputDataType; + this.outDataJson = outDataJson; this.outDataJsonObj = outDataJsonObj; + } +} --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/DefaultSegmentTextExtractor.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/DefaultSegmentTextExtractor.java @@ -56,10 +56,13 @@ @Resource private EnglishDictConfig englishDictConfig; @Resource + private cn.jojo.cc.core.service.question.standard.ContentNormConfigCoreService contentNormConfigCoreService; + + @Resource private FormDataCoreService formDataCoreService; @Resource private OutputDataCoreService outputDataCoreService; @@ -91,31 +94,54 @@ this.historicalContentResolver = historicalContentResolver; } @Override public Map> extractRichTextsByContentType(QuestionDo question) { - if (question == null || question.getId() == null || question.getQuestionType() == null) { - return new LinkedHashMap<>(0); - } - if (!currentWhitelist().contains(question.getQuestionType())) { - return new LinkedHashMap<>(0); - } - try { - String formDataJson = resolveFormDataJson(question); - if (StringUtils.isNotBlank(formDataJson)) { - return extractFromFormData(question.getQuestionType(), formDataJson); - } - String historicalJson = resolveHistoricalContentJson(question); - if (StringUtils.isNotBlank(historicalJson)) { - return extractFromHistoricalContent(historicalJson); - } - return new LinkedHashMap<>(0); - } catch (Exception e) { - log.error("标准题富文本抽取失败, questionId={}, questionType={}", - question.getId(), question.getQuestionType(), e); - throw e; - } + return readRichTextSource(question).richTexts(); + } + + @Override + public RichTextSource readRichTextSource(QuestionDo question) { + if (question == null || question.getId() == null || question.getQuestionType() == null + || !currentWhitelist().contains(question.getQuestionType())) { + return new RichTextSource(new LinkedHashMap<>(), null); + } + if (formDataResolver != null) { + String json = formDataResolver.apply(question); + return new RichTextSource(StringUtils.isBlank(json) ? new LinkedHashMap<>() + : extractFromFormData(question.getQuestionType(), json), null); + } + // 真实表单业务键是内容配置ID(DefaultFormDataProcessing.saveContentNormConfigDo)。 + List configs = + contentNormConfigCoreService == null ? List.of() : contentNormConfigCoreService.listByQuestionId(question.getId()); + Long configId = configs == null || configs.isEmpty() ? question.getId() : configs.get(0).getId(); + FormDataDo form = formDataCoreService == null ? null : formDataCoreService.get(null, configId, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()); + if (form != null && form.getFormData() != null) { + JSON input = (JSON) JSON.parse(JSON.toJSONString(form.getFormData(), + com.alibaba.fastjson.serializer.SerializerFeature.WriteMapNullValue)); + String inputHash = SegmentPayloadIdentity.hash(input); + String outputHash = null; + if (outputDataCoreService != null) { + List generated = outputDataCoreService.listByFormDataIdsAndType(List.of(form.getId()), + OutPutDataTypeEnum.GAME.getType(), 0); + // 只认同一输入实际生成过的输出;不把当前DB输入hash贴到旧输出上。 + for (OutputDataDo output : generated) { + if (java.util.Objects.equals(inputHash, output.getSegmentSourcePayloadHash()) + && StringUtils.isNotBlank(output.getOutputData())) { + outputHash = SegmentPayloadIdentity.outputHash((JSON) JSON.parse(output.getOutputData())); + } + } + } + return new RichTextSource(extractFromFormData(question.getQuestionType(), input.toJSONString()), + new SegmentPayloadIdentity(inputHash, form.getId(), form.getBusinessId(), outputHash)); + } + // 没有原输入时仅保留历史匹配能力;无可信来源身份,RPC不会冒认与输出URL一致。 + String historical = resolveHistoricalContentJson(question); + return new RichTextSource(StringUtils.isBlank(historical) ? new LinkedHashMap<>() + : extractFromHistoricalContent(historical), null); } @Override public String extractPlainText(String richText) { if (StringUtils.isBlank(richText)) { @@ -137,37 +163,10 @@ // 否则与候选题扫描的解读相反,同一份配置会出现两种行为。 return englishDictConfig.getQuestionTypeWhitelist(); } // 未装配配置对象的只有单测路径,取值来自 setQuestionTypeWhitelist 测试钩子。 return questionTypeWhitelist; - } - - private String resolveFormDataJson(QuestionDo question) { - if (formDataResolver != null) { - return formDataResolver.apply(question); - } - if (formDataCoreService == null) { - return null; - } - FormDataDo formData = formDataCoreService.get(null, question.getId(), - FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), - BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()); - if (formData == null) { - return null; - } - if (formData.getFormData() != null) { - return formData.getFormData().toJSONString(); - } - if (outputDataCoreService == null) { - return null; - } - List outputs = outputDataCoreService.listByFormDataIdsAndType( - List.of(formData.getId()), OutPutDataTypeEnum.GAME.getType(), 0); - if (outputs == null || outputs.isEmpty()) { - return null; - } - return outputs.get(0).getOutputData(); } private String resolveHistoricalContentJson(QuestionDo question) { if (historicalContentResolver != null) { return historicalContentResolver.apply(question); --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizService.java @@ -43,16 +43,10 @@ @Slf4j @Service public class EnglishSegmentDeliveryBizService { @Resource - private cn.jojo.cc.core.service.question.library.QuestionCoreService questionCoreService; - - @Resource - private SegmentContentSnapshotService segmentContentSnapshotService; - - @Resource private QuestionSegmentCoreService questionSegmentCoreService; @Resource private EnglishDictConfig englishDictConfig; @@ -73,10 +67,15 @@ * @param questionKeys 题目的 questionKey 集合,须与库内 {@code question_key} 同形态 * @return questionKey -> 已按当前词典解析过 ID 的划词序列;无分词行或解析失败的 key 不在结果中 */ @org.springframework.transaction.annotation.Transactional(readOnly = true) public Map resolveByQuestionKeys(Set questionKeys) { + return Collections.emptyMap(); // 未传本次输出身份,不从新DB猜测旧响应的版本。 + } + + public Map resolveByQuestionKeys(Set questionKeys, + Map selectedOutputs) { // 回滚契约(总开关一键止血):关闭后不查库、整批不下发 segments, // 题目照常返回、C 端划词一律降级为不可点;仅关新增落库不叫止血。 if (!englishDictConfig.isSegmentEnabled()) { return Collections.emptyMap(); } @@ -102,34 +101,29 @@ if (StringUtils.isBlank(row.getQuestionKey())) { continue; } rowsByKey.computeIfAbsent(row.getQuestionKey(), key -> new ArrayList<>()).add(row); } - Map currentByKey = new HashMap<>(); - List keys = new ArrayList<>(rowsByKey.keySet()); - for (int offset = 0; offset < keys.size(); offset += 100) { - for (cn.jojo.cc.core.domain.question.library.QuestionDo current : questionCoreService.listByKeys( - keys.subList(offset, Math.min(keys.size(), offset + 100)))) { - currentByKey.put(current.getQuestionKey(), current); - } - } Map result = new HashMap<>(rowsByKey.size()); for (Map.Entry> entry : rowsByKey.entrySet()) { EnglishSegmentDto merged; try { - cn.jojo.cc.core.domain.question.library.QuestionDo current = currentByKey.get(entry.getKey()); - if (current == null) { continue; } - Map> texts = segmentContentSnapshotService.readTexts(current); - Set storedTypes = new LinkedHashSet<>(); - entry.getValue().forEach(row -> storedTypes.add(row.getContentType())); - if (!storedTypes.equals(texts.keySet())) { continue; } - String fingerprint = segmentContentSnapshotService.fingerprint(texts); - if (entry.getValue().stream().anyMatch(row -> !java.util.Objects.equals( - fingerprint, row.getSourceTextMd5()))) { continue; } + SelectedSegmentOutput selected = selectedOutputs == null ? null : selectedOutputs.get(entry.getKey()); + if (selected == null || !selected.known() || selected.source() != source) { continue; } + SegmentPayloadIdentity identity = selected.identity(); + boolean same = true; + for (QuestionSegmentDo row : entry.getValue()) { + SegmentSnapshot stored = JSON.parseObject(row.getSegments(), SegmentSnapshot.class); + same &= stored != null && java.util.Objects.equals(identity.payloadHash(), stored.getSourcePayloadHash()) + && java.util.Objects.equals(identity.formDataId(), stored.getSourceFormDataId()) + && java.util.Objects.equals(identity.contentConfigId(), stored.getSourceContentConfigId()) + && java.util.Objects.equals(identity.outputPayloadHash(), stored.getSourceOutputPayloadHash()); + } + if (!same) { continue; } merged = resolveGroup(source, entry.getValue()); } catch (Exception e) { - log.warn("核验当前正文指纹失败,本题不下发划词, questionKey={}", entry.getKey(), e); + log.warn("核验本次响应来源身份失败,本题不下发划词, questionKey={}", entry.getKey(), e); continue; } if (merged != null) { result.put(entry.getKey(), merged); } --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentOutputIdentityRecoveryBizService.java @@ -0,0 +1,100 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.cache.FormAndDataCacheService; +import cn.jojo.cc.biz.cache.key.FormAndDataCacheKey; +import cn.jojo.cc.biz.model.bo.CommonCacheBo; +import cn.jojo.cc.biz.model.cache.FormAndDataCacheBo; +import cn.jojo.cc.biz.service.question.QuestionBizService; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.DataSourceEnum; +import cn.jojo.cc.common.enums.RedisKeyEnum; +import cn.jojo.cc.common.enums.question.FormDataBusinessTypeEnum; +import cn.jojo.cc.common.enums.question.BusinessTypeClassifyEnum; +import cn.jojo.cc.common.enums.question.OutPutDataTypeEnum; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import cn.jojo.cc.core.domain.schema.common.data.OutputDataDo; +import cn.jojo.cc.core.multidatasource.MultiDataSourceUtil; +import cn.jojo.cc.core.service.question.library.QuestionCoreService; +import cn.jojo.cc.core.service.schema.OutputDataCoreService; +import cn.jojo.cc.core.service.english.EnglishDictOutboxCoreService; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.redisson.api.RedissonClient; +import org.springframework.stereotype.Service; +import java.util.List; +import java.util.Set; +import java.util.Objects; + +/** 滚动升级恢复:旧输出必须真实重生成,不能给旧URL贴新输入hash。 */ +@Service +@Slf4j +public class EnglishSegmentOutputIdentityRecoveryBizService { + @Resource private QuestionCoreService questions; + @Resource private QuestionBizService questionBizService; + @Resource private OutputDataCoreService outputs; + @Resource private SegmentContentSnapshotService content; + @Resource private QuestionSegmentBizService segments; + @Resource private EnglishSegmentSkeletonBizService skeletons; + @Resource private EnglishDictOutboxCoreService outbox; + @Resource private RedissonClient redissonClient; + + public String runOnce(LoginUserBo operator) { + int failed = 0; + StringBuilder summary = new StringBuilder(); + for (DataBaseTypeEnum env : new DataBaseTypeEnum[]{DataBaseTypeEnum.TEACHING, DataBaseTypeEnum.PROD}) { + DataSourceEnum dataSource = env == DataBaseTypeEnum.PROD ? DataSourceEnum.PROD : DataSourceEnum.DEFAULT; + var cursor = redissonClient.getAtomicLong(RedisKeyEnum.ENGLISH_SEGMENT_REFRESH_LOCK.buildKey() + + ":output-source-cursor:" + env.name()); + long after = cursor.get(); + List batch = MultiDataSourceUtil.switchThen(dataSource, + () -> questions.listSegmentCandidatesByIdGt(after, 50, env)); + for (QuestionDo question : batch) { + try { + MultiDataSourceUtil.switchThen(dataSource, () -> { recover(question, env, operator); return null; }); + } catch (Exception e) { + failed++; + log.error("输出来源恢复失败,保留fail-closed并推进后续题, env={}, id={}", env, question.getId(), e); + } + cursor.set(question.getId()); + } + boolean finished = batch.size() < 50; + if (finished) { cursor.set(0L); } + summary.append(env).append(" scanned=").append(batch.size()).append(" finished=").append(finished).append("; "); + } + return summary.append("failed=").append(failed).toString(); + } + + private void recover(QuestionDo question, DataBaseTypeEnum env, LoginUserBo operator) { + SegmentPayloadIdentity identity = content.readSource(question).identity(); + if (identity == null || !identity.inputKnown()) { throw new IllegalStateException("原FormData不可用,不能伪造输出身份"); } + if (!hasMatchingOutput(identity)) { + // 现有公开入口按真实schema重生成并上传输出,genOutPutData在生成前记录输入身份。 + questionBizService.refreshGenStandQuestion(question.getId(), operator); + identity = content.readSource(question).identity(); + if (identity == null || !identity.inputKnown() || !hasMatchingOutput(identity)) { + throw new IllegalStateException("输出未能按当前输入重生成,保持不可点击"); + } + } + FormAndDataCacheKey key = new FormAndDataCacheKey(identity.contentConfigId(), + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType(), OutPutDataTypeEnum.GAME.getType()); + CommonCacheBo evict = + CommonCacheBo.builder().bizType(QuestionDo.class) + .bizId(question.getId()).ids(Set.of(key)).type(FormAndDataCacheService.class).build(); + outbox.enqueue("FORM_OUTPUT_CACHE", question.getId(), JSON.toJSONString(List.of(evict), SerializerFeature.WriteClassName)); + if (env == DataBaseTypeEnum.PROD) { + skeletons.ensurePair(cn.jojo.cc.biz.service.component.CommonService.incrementAndGet(question.getId()), question.getId()); + } + segments.refreshOne(question, env, operator); + } + + private boolean hasMatchingOutput(SegmentPayloadIdentity identity) { + return outputs.listByFormDataIdsAndType(List.of(identity.formDataId()), + OutPutDataTypeEnum.GAME.getType(), 0).stream().anyMatch(output -> + Objects.equals(identity.payloadHash(), output.getSegmentSourcePayloadHash()) + && output.getUrl() != null && !output.getUrl().isBlank()); + } +} --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentBizServiceImpl.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentBizServiceImpl.java @@ -245,19 +245,21 @@ boolean reuseStoredResult = false; // 本次实际抽取出的内容位必须在 try 外可见:catch 登记失败行时要按“这一版正文属于哪些内容位”, // 不能拿库里的旧行反推。 Map> textsByType = new TreeMap<>(); try { - textsByType = extractPlainTextsByContentType(question); + SegmentContentSnapshotService.ContentSource contentSource = segmentContentSnapshotService.readSource(question); + textsByType = contentSource.texts(); sourceTextMd5 = segmentContentSnapshotService.fingerprint(textsByType); // 一次读出该题全部内容位行:剪枝、修订判定与拒绝分支都要按内容位各看自己那一行。 Map existingByType = toMapByType(questionSegmentCoreService.listByQuestionId(question.getId())); for (QuestionSegmentDo existing : existingByType.values()) { if (isStale(existing, question.getUpdateTime())) { return new SegmentResult(); } } - if (!force && canSkipAll(existingByType, textsByType.keySet(), question, sourceTextMd5)) { + if (!force && canSkipAll(existingByType, textsByType.keySet(), question, sourceTextMd5) + && samePayloadIdentity(existingByType, contentSource.identity())) { // 剪枝命中:库里已有同正文、同修订、同内容位集合的成功结果。直接回显库存,绝不重算—— // 重算会用“当前词典”得出与库存不一致的 segments,而本分支又不落库(词典变更靠每日全量刷传播)。 reuseStoredResult = true; return mergeStored(existingByType); } @@ -280,10 +282,11 @@ } Map resultsByType = matchAllTypesByType(question, segmentSource, snapshot, textsByType); for (SegmentResult one : resultsByType.values()) { one.setSourceTextMd5(sourceTextMd5); + one.setSourceIdentity(contentSource.identity()); } for (Map.Entry entry : resultsByType.entrySet()) { if (!isStale(existingByType.get(entry.getKey()), question.getUpdateTime())) { continue; } @@ -385,10 +388,22 @@ /** * 整体剪枝:库里现存内容位集合与本次抽取的类型集完全一致,且每一行都是同正文同修订的成功行。 * 集合不等(如新增了内容位)时必须重算,不能因“已有行可跳过”而漏掉新内容位。 */ + private boolean samePayloadIdentity(Map rows, SegmentPayloadIdentity identity) { + if (identity == null || !identity.known()) { return true; } // 无新凭据不伪造身份;RPC仍不采信旧缺凭据行。 + for (QuestionSegmentDo row : rows.values()) { + SegmentSnapshot stored = JSON.parseObject(row.getSegments(), SegmentSnapshot.class); + if (stored == null || !Objects.equals(identity.payloadHash(), stored.getSourcePayloadHash()) + || !Objects.equals(identity.formDataId(), stored.getSourceFormDataId()) + || !Objects.equals(identity.contentConfigId(), stored.getSourceContentConfigId()) + || !Objects.equals(identity.outputPayloadHash(), stored.getSourceOutputPayloadHash())) { return false; } + } + return true; + } + private boolean canSkipAll(Map existingByType, Set incomingTypes, QuestionDo question, String sourceTextMd5) { if (existingByType.isEmpty() || !existingByType.keySet().equals(incomingTypes)) { return false; } @@ -437,11 +452,23 @@ segmentDo.setContentType(contentType); segmentDo.setQuestionKey(question.getQuestionKey()); segmentDo.setSourceTextMd5(result.getSourceTextMd5()); segmentDo.setQuestionRevision(question.getUpdateTime()); // 落库只存文本与类型、不存词条 ID:ID 由下发时按当前词典现算(理由见 SegmentSnapshot 类注释)。 - segmentDo.setSegments(JSON.toJSONString(SegmentSnapshot.from(result.getSegments()))); + SegmentSnapshot snapshot = SegmentSnapshot.from(result.getSegments()); + SegmentPayloadIdentity identity = result.getSourceIdentity(); + if (identity != null && identity.known()) { + snapshot.setSourcePayloadHash(identity.payloadHash()); + snapshot.setSourceFormDataId(identity.formDataId()); + snapshot.setSourceContentConfigId(identity.contentConfigId()); + snapshot.setSourceOutputPayloadHash(identity.outputPayloadHash()); + } + String storedJson = JSON.toJSONString(snapshot); + if (storedJson.length() > 3000) { + throw new IllegalStateException("segments超过VARCHAR3000,保留FAILED而非截断"); + } + segmentDo.setSegments(storedJson); segmentDo.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); segmentDo.setRetryCount(0); if (operator != null) { // V3 审计只存操作人 ID(新表无人名列),人名仅存在于登录态/日志。 segmentDo.setCreatorId(operator.getLoginUserId()); --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentContentSnapshotService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentContentSnapshotService.java @@ -13,25 +13,32 @@ /** 计算、失败恢复和下发共用的正文/内容位指纹;不拿审计时间代替内容版本。 */ @Service public class SegmentContentSnapshotService { @Resource private SegmentTextExtractor segmentTextExtractor; - public Map> readTexts(QuestionDo question) { + public record ContentSource(Map> texts, SegmentPayloadIdentity identity) { } + + public Map> readTexts(QuestionDo question) { return readSource(question).texts(); } + + public ContentSource readSource(QuestionDo question) { Map> grouped = new TreeMap<>(); - if (question == null || question.getId() == null) { return grouped; } - Map> richTexts = segmentTextExtractor.extractRichTextsByContentType(question); - if (richTexts == null) { return grouped; } + if (question == null || question.getId() == null) { return new ContentSource(grouped, null); } + SegmentTextExtractor.RichTextSource source = segmentTextExtractor.readRichTextSource(question); + // 兼容历史自定义抽取器;没有来源凭据时RPC仍fail-closed。 + Map> richTexts = source == null + ? segmentTextExtractor.extractRichTextsByContentType(question) : source.richTexts(); + if (richTexts == null) { return new ContentSource(grouped, null); } for (Map.Entry> entry : richTexts.entrySet()) { List plain = new ArrayList<>(); for (String rich : entry.getValue()) { if (StringUtils.isBlank(rich)) { continue; } String text = segmentTextExtractor.extractPlainText(rich); if (StringUtils.isNotBlank(text)) { plain.add(text); } } if (!plain.isEmpty()) { grouped.put(entry.getKey(), plain); } } - return grouped; + return new ContentSource(grouped, source == null ? null : source.identity()); } public String fingerprint(Map> grouped) { List all = new ArrayList<>(); new TreeMap<>(grouped).values().forEach(all::addAll); --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentPayloadIdentity.java @@ -0,0 +1,40 @@ +package cn.jojo.cc.biz.service.english.segment; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.serializer.SerializerFeature; +import cn.jojo.cc.biz.utils.MD5Util; +import java.util.*; + +/** 原始FormData生成输入的v1稳定身份;ID独立比较,不把别库ID混入正文hash。 */ +public record SegmentPayloadIdentity(String payloadHash, Long formDataId, Long contentConfigId, String outputPayloadHash) { + public SegmentPayloadIdentity(String hash, Long formId, Long configId) { this(hash, formId, configId, null); } + public boolean known() { return inputKnown() && outputPayloadHash != null + && outputPayloadHash.matches("output-v1:[a-f0-9]{32}"); } + public boolean inputKnown() { + return payloadHash != null && payloadHash.matches("form-v1:[a-f0-9]{32}") + && formDataId != null && contentConfigId != null; + } + public static String hash(JSON formData) { + if (formData == null) { return null; } + return "form-v1:" + MD5Util.creatMd5(JSON.toJSONString(canonical(formData), + SerializerFeature.MapSortField, SerializerFeature.WriteMapNullValue)); + } + public static String outputHash(JSON output) { + if (output == null) { return null; } + return "output-v1:" + MD5Util.creatMd5(JSON.toJSONString(canonical(output), + SerializerFeature.MapSortField, SerializerFeature.WriteMapNullValue)); + } + private static Object canonical(Object value) { + if (value instanceof Map map) { + Map result = new TreeMap<>(); + map.forEach((k, v) -> result.put(String.valueOf(k), canonical(v))); + return result; + } + if (value instanceof Iterable list) { + List result = new ArrayList<>(); + list.forEach(item -> result.add(canonical(item))); + return result; + } + return value; + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentResult.java @@ -0,0 +1,23 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import lombok.Data; + +import java.util.LinkedHashSet; +import java.util.Set; + +/** + * 单题分词结果。 + */ +@Data +public class SegmentResult { + + private EnglishSegmentDto segments = new EnglishSegmentDto(); + + private Set unmatchedWords = new LinkedHashSet<>(); + + private String sourceTextMd5; + + private Long questionRevision; + private SegmentPayloadIdentity sourceIdentity; +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentTextExtractor.java @@ -0,0 +1,47 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.core.domain.question.library.QuestionDo; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * 从题目内容中抽取允许分词的富文本。 + * + *

抽取结果按 PRD 的“内容生产项”分组:标准选择题只有文字材料,标准填空/完形填空/ + * 标准输入题只有作答区文字,两两互斥。同类型内的多个文本块保持原顺序,由调用方合并成一条序列, + * 不能先拼接再匹配(那样只能得到一条混合序列,失去“每内容位一条”的粒度)。

+ */ +public interface SegmentTextExtractor { + + record RichTextSource(Map> richTexts, SegmentPayloadIdentity identity) { } + + default RichTextSource readRichTextSource(QuestionDo question) { + return new RichTextSource(extractRichTextsByContentType(question), null); + } + + /** + * 按内容位类型分组抽取,key 为 {@code SegmentContentTypeEnum#getCode}。 + * + * @return 类型 -> 该类型的富文本列表;题型不在白名单或抽不到内容时返回空 Map + */ + Map> extractRichTextsByContentType(QuestionDo question); + + /** + * 抽取全部生产项(不分类,按内容位顺序拼接)。仅用于不关心类型边界的调用方与测试。 + */ + default List extractRichTexts(QuestionDo question) { + List merged = new ArrayList<>(); + Map> grouped = extractRichTextsByContentType(question); + if (grouped != null) { + grouped.values().forEach(merged::addAll); + } + return merged; + } + + /** + * 将富文本转换为用于匹配的纯文本。 + */ + String extractPlainText(String richText); +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SelectedSegmentOutput.java @@ -0,0 +1,19 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.common.enums.DataBaseTypeEnum; + +/** 本次RPC已选输出的不可变凭据,与返回URL来自同一个缓存BO;绝不现场贴新DB身份。 */ +public record SelectedSegmentOutput(DataBaseTypeEnum source, String outputUrl, SegmentPayloadIdentity identity) { + public boolean known() { return source != null && outputUrl != null && !outputUrl.isBlank() + && identity != null && identity.known(); } + public static SelectedSegmentOutput capture(DataBaseTypeEnum source, Long selectedConfigId, + cn.jojo.cc.biz.model.cache.FormAndDataCacheBo selected, String responseUrl) { + if (selected == null || !java.util.Objects.equals(selectedConfigId, selected.getBusinessId()) + || !java.util.Objects.equals(responseUrl, cn.jojo.cc.biz.utils.UriToolsUtil.getPath(selected.getOutDataJson()))) { + return null; + } + return new SelectedSegmentOutput(source, responseUrl, new SegmentPayloadIdentity( + selected.getSegmentSourcePayloadHash(), selected.getSegmentSourceFormDataId(), selectedConfigId, + SegmentPayloadIdentity.outputHash(selected.getOutDataJsonObj()))); + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/impl/schema/data/FormDataBizServiceImpl.java @@ -0,0 +1,555 @@ +package cn.jojo.cc.biz.service.impl.schema.data; + +import cn.jojo.cc.biz.cache.key.FormAndDataCacheKey; +import cn.jojo.cc.biz.cache.key.FormDataCacheKey; +import cn.jojo.cc.biz.model.bo.question.standard.BusinessItemParamBo; +import cn.jojo.cc.biz.model.bo.question.standard.SchemaDataSyncBo; +import cn.jojo.cc.biz.model.bo.schema.FormDataDetailBo; +import cn.jojo.cc.biz.model.bo.schema.FormDataSaveBo; +import cn.jojo.cc.biz.model.bo.schema.FormDateSaveResultBo; +import cn.jojo.cc.biz.model.bo.schema.SchemaFlagFieldPathBo; +import cn.jojo.cc.biz.model.cache.FormAndDataCacheBo; +import cn.jojo.cc.biz.model.cache.FormDataCacheBo; +import cn.jojo.cc.biz.service.ResourceManifestBizService; +import cn.jojo.cc.biz.service.SnapshotBizService; +import cn.jojo.cc.biz.service.component.CommonService; +import cn.jojo.cc.biz.service.impl.schema.process.DefaultFormDataProcessing; +import cn.jojo.cc.biz.service.impl.schema.process.SchemaUtils; +import cn.jojo.cc.biz.service.impl.schema.process.context.FormDataDetailContext; +import cn.jojo.cc.biz.service.impl.schema.process.context.FormDataSaveContext; +import cn.jojo.cc.biz.service.impl.schema.process.resolver.FormDataParamResolver; +import cn.jojo.cc.biz.service.kv.metadata.MetaData; +import cn.jojo.cc.biz.service.schema.BusinessSchemaRelationBizService; +import cn.jojo.cc.biz.service.schema.FormDataBizService; + +import cn.jojo.cc.biz.service.schema.FormSchemaBizService; +import cn.jojo.cc.biz.service.schema.OutputDataBizService; +import cn.jojo.cc.biz.service.synch.SynchBizService; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaConstants; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaUtil; +import cn.jojo.cc.common.constants.Constants; +import cn.jojo.cc.common.constants.RedisKeyConstants; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.DeleteStatusEnum; +import cn.jojo.cc.common.enums.ResourceManifestBizTypeEnum; +import cn.jojo.cc.common.enums.question.BusinessTypeClassifyEnum; +import cn.jojo.cc.common.enums.question.FormDataBusinessTypeEnum; +import cn.jojo.cc.common.enums.question.OutPutDataTypeEnum; +import cn.jojo.cc.common.exception.BusinessException; +import cn.jojo.cc.common.exception.FormDataBizCheckException; +import cn.jojo.cc.common.utils.MessageSourceUtils; +import cn.jojo.cc.common.utils.SpringUtils; +import cn.jojo.cc.core.bo.LoginUserBo; + +import cn.jojo.cc.core.dao.schema.FormDataDao; +import cn.jojo.cc.core.domain.module.step.StepComponentDo; +import cn.jojo.cc.core.domain.schema.common.data.FormDataDo; +import cn.jojo.cc.core.domain.schema.common.data.OutputDataDo; +import cn.jojo.cc.core.domain.schema.common.schema.FormSchemaDo; +import cn.jojo.cc.core.service.module.step.StepComponentCoreService; +import cn.jojo.cc.core.service.schema.BusinessSchemaRelationCoreService; +import cn.jojo.cc.core.service.schema.FormDataCoreService; +import cn.jojo.cc.core.service.schema.FormSchemaCoreService; +import cn.jojo.cc.core.service.schema.OutputDataCoreService; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.JSONPath; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.commons.lang3.tuple.Pair; +import org.redisson.api.RLock; +import org.redisson.api.RedissonClient; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.Resource; +import jakarta.validation.Valid; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; + + +/** + * 描述: + * + * @author: ZHR + * @data: 2024-03-11 + */ +@Service +@Slf4j +public class FormDataBizServiceImpl implements FormDataBizService { + + @Resource + private FormDataCoreService formDataCoreService; + + @Resource + private OutputDataBizService outputDataBizService; + + @Resource + private OutputDataCoreService outputDataCoreService; + + @Autowired + private RedissonClient redissonClient; + + @Resource + private FormSchemaCoreService formSchemaCoreService; + + @Resource + private StepComponentCoreService stepComponentCoreService; + + @Resource + private ResourceManifestBizService resourceManifestBizService; + + @Override + public FormDateSaveResultBo formDataSave(FormDataSaveBo bo, LoginUserBo userBo) { + FormDataSaveContext context = new FormDataSaveContext(); + context.setFormIMessage(bo); + context.setUserBo(userBo); + if (Objects.nonNull(bo.getMetaData())) { + context.setMetaData(bo.getMetaData()); + } + // 加锁执行 避免并发问题 + String lockValue = bo.getBusinessType() + "_" + bo.getBusinessTypeClassify() + "_" + bo.getBusinessId(); + String lockKey = new StringBuilder(RedisKeyConstants.LOCK_FORM_SCHEMA_SAVE).append(lockValue).toString(); + RLock lock = redissonClient.getLock(lockKey); + boolean lockFlag; + try { + lockFlag = lock.tryLock(0, 10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + throw BusinessException.paramException( + MessageSourceUtils.getMixedMessage("error_local.biz.service.form_data_1") + ); + } + if (lockFlag) { + try { + DefaultFormDataProcessing defaultFormDataProcessing = SpringUtils.getBean(DefaultFormDataProcessing.class); + defaultFormDataProcessing.formDataSaveProcess(context); + // 标准题需要创建资源清单收集任务 + if (Objects.equals(bo.getBusinessType(), FormDataBusinessTypeEnum.STANDARD_QUESTION.getCode())) { + // 创建资源清单收集任务 + this.resourceManifestBizService.createCollectTask( + ResourceManifestBizTypeEnum.LIBRARY_QUESTION, + bo.getBusinessId(), + DataBaseTypeEnum.TEACHING, + userBo + ); + } + } catch (FormDataBizCheckException ex) { + return FormDateSaveResultBo.builder() + .code(Constants.RESP_CODE_ERROR) + .checkType(ex.getCheckTypeEnum().getCode()) + .errMessage(ex.getData()) + .build(); + } finally { + if (lock.isHeldByCurrentThread()) { + lock.unlock(); + } + } + } else { + throw BusinessException.paramException( + MessageSourceUtils.getMixedMessage("error_local.biz.service.form_data_2") + ); + } + return FormDateSaveResultBo.builder() + .code(Constants.RESP_CODE_SUCCESS) + .id( + Optional.ofNullable(context.getFormDataDo()).map(FormDataDo::getId).orElse(Constants.DEFAULT_ID) + ) + .build(); + } + + @Override + public JSON formDataDetail(FormDataDetailBo bo, LoginUserBo userBo) { + FormDataDetailContext context = new FormDataDetailContext(bo); + DefaultFormDataProcessing defaultFormDataProcessing = SpringUtils.getBean(DefaultFormDataProcessing.class); + return defaultFormDataProcessing.formDataDetailProcess(context); + } + + @Override + public void publishFormData(Collection businessIds, Integer bsType, Integer bsTypeClassify) { + if (CollectionUtils.isEmpty(businessIds)) { + return; + } + List formDataDoList = + formDataCoreService.listByBusinessIds(businessIds, bsType, bsTypeClassify); + if (CollectionUtils.isEmpty(formDataDoList)) { + return; + } + + this.formDataTableRelation(formDataDoList); + + SynchBizService synchBizService = SpringUtils.getBean(SynchBizService.class); + synchBizService.publishFormData(formDataDoList); + + Set formDataIds = formDataDoList.stream().map(FormDataDo::getId).collect(Collectors.toSet()); + outputDataBizService.publishOutputData(formDataIds); + } + + @Override + @Transactional + public void publishFormData(List formDataDoList) { + if (CollectionUtils.isEmpty(formDataDoList)) { + return; + } + formDataTableRelation(formDataDoList); + + SynchBizService synchBizService = SpringUtils.getBean(SynchBizService.class); + synchBizService.publishFormData(formDataDoList); + + Set formDataIds = formDataDoList.stream().map(FormDataDo::getId).collect(Collectors.toSet()); + outputDataBizService.publishOutputData(formDataIds); + } + + @Transactional(rollbackFor = Exception.class) + @Override + public void formDataTableRelation(List formDataDoList) { + if (CollectionUtils.isEmpty(formDataDoList)) { + return; + } + //执行减一亿的操作 + Set formSchemaIds = formDataDoList.stream().map(FormDataDo::getFormSchemaId).collect(Collectors.toSet()); + List formSchemaDoList = formSchemaCoreService.listByIds(formSchemaIds); + Map schemaIdSchemaMap = formSchemaDoList + .stream() + .collect(Collectors.toMap(FormSchemaDo::getId, FormSchemaDo::getFormSchema)); + + for (FormDataDo formDataDo : formDataDoList) { + Long schemaId = formDataDo.getFormSchemaId(); + if (!schemaIdSchemaMap.containsKey(schemaId)) { + continue; + } + String schema = schemaIdSchemaMap.get(schemaId); + Pair> idFieldsPair = SchemaUtils.resolveFlagField( + schema, JSON.toJSONString(formDataDo.getFormData()), JsonSchemaConstants.TABLE_RELATION, null + ); + if (Objects.isNull(idFieldsPair)) { + continue; + } + List idFields = idFieldsPair.getRight() + .stream() + .filter(e -> Objects.nonNull(e.getFieldValue())) + .collect(Collectors.toList()); + if (CollectionUtils.isEmpty(idFields)) { + continue; + } + idFields.stream().forEach(filed -> + { + //数组类型并且数组元素是number类型的节点数据需要执行数组元素减一亿的操作 + if (JsonSchemaConstants.TYPE_ARRAY.equals(filed.getFieldType()) && + JsonSchemaConstants.TYPE_NUMBER.equals(filed.getJsonNode().get( + JsonSchemaConstants.JSON_NODE_ITEMS).get(JsonSchemaConstants.JSON_NODE_TYPE).textValue())) { + JSONArray valueArray = (JSONArray) filed.getFieldValue(); + List tableRelationList = valueArray.stream() + .map(e -> CommonService.decrementAndGet(Long.valueOf(e.toString()))) + .collect(Collectors.toList()); + JSONPath.set( + formDataDo.getFormData(), + filed.getPath(), + tableRelationList + ); + } else { + JSONPath.set( + formDataDo.getFormData(), + filed.getPath(), + CommonService.decrementAndGet(Long.valueOf(filed.getFieldValue().toString())) + ); + } + } + ); + } + } + + @Override + public List listFormAndDataCacheBo(Collection businessIds, + Integer businessType, + Integer businessTypeClassify, + Integer outPutDataType) { + List formDataDoList = + formDataCoreService.listBySimpleBusinessIds(businessIds, businessType, businessTypeClassify); + Set formDataIds = formDataDoList.stream().map(FormDataDo::getId).collect(Collectors.toSet()); + List outputDataDoList = outputDataCoreService.listSimpleByFormDataIdsAndType(formDataIds, + OutPutDataTypeEnum.GAME.getType(), + DeleteStatusEnum.NOT_DELETE.getValue()); + Map formDataIdMap = + outputDataDoList.stream().collect(Collectors.toMap(OutputDataDo::getFormDataId, Function.identity(), + (k1, k2) -> k2)); + List resultList = formDataDoList.stream().map(e -> { + FormAndDataCacheBo bo = new FormAndDataCacheBo(); + bo.setBusinessId(e.getBusinessId()); + bo.setBusinessType(businessType); + bo.setBusinessTypeClassify(businessTypeClassify); + bo.setOutputDataType(outPutDataType); + bo.setOutDataJson( + Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getUrl).orElse(StringUtils.EMPTY)); + OutputDataDo selectedOutput = formDataIdMap.get(e.getId()); + if (selectedOutput != null) { + bo.setSegmentSourceFormDataId(selectedOutput.getFormDataId()); + bo.setSegmentSourcePayloadHash(selectedOutput.getSegmentSourcePayloadHash()); + } + String outPutData = Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getOutputData) + .orElse(StringUtils.EMPTY); + if (StringUtils.isNotBlank(outPutData)) { + try { + bo.setOutDataJsonObj(JSON.parseObject(outPutData)); + } catch (Exception exception) { + log.error("[FormDataBizServiceImpl.listFormAndDataCacheBo] outPutData not a standard json,id:{}", + e.getId()); + bo.setOutDataJsonObj(null); + } + } + return bo; + }).collect(Collectors.toList()); + return resultList; + } + + @Override + public List listFormAndDataCacheBo(Set cacheKeys) { + if (CollectionUtils.isEmpty(cacheKeys)) { + return new ArrayList<>(0); + } + Map>> cacheKeyMap = cacheKeys.stream() + .collect( + Collectors.groupingBy( + FormAndDataCacheKey::getBusinessType, + Collectors.groupingBy(FormAndDataCacheKey::getBusinessTypeClassify, + Collectors.mapping(FormAndDataCacheKey::getBusinessId, Collectors.toSet())) + ) + ); + List formDataDoList = new ArrayList<>(); + + //查询标准题配置内容 + if (cacheKeyMap.containsKey(FormDataBusinessTypeEnum.STANDARD_QUESTION.getType())) { + Map> businessTypeClassifyMap = + cacheKeyMap.get(FormDataBusinessTypeEnum.STANDARD_QUESTION.getType()); + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType())) { + Set contentNormConfigIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()); + List contentNormFormDataList = formDataCoreService.listBySimpleBusinessIds( + contentNormConfigIds, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType() + ); + formDataDoList.addAll(contentNormFormDataList); + } + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType())) { + Set questionIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType()); + List questionPublicFormDataList = formDataCoreService.listBySimpleBusinessIds( + questionIds, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_PUBLIC.getType() + ); + formDataDoList.addAll(questionPublicFormDataList); + } + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.EXPLAINS.getType())) { + Set questionIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.EXPLAINS.getType()); + List explianFormDataList = formDataCoreService.listBySimpleBusinessIds( + questionIds, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.EXPLAINS.getType() + ); + formDataDoList.addAll(explianFormDataList); + } + } + //查询环节模块配置内容 + if (cacheKeyMap.containsKey(FormDataBusinessTypeEnum.STEP_COMPONENT.getType())) { + Map> businessTypeClassifyMap = + cacheKeyMap.get(FormDataBusinessTypeEnum.STEP_COMPONENT.getType()); + + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.STEP_COMPONENT.getType())) { + Set stepComponentIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.STEP_COMPONENT.getType()); + List moduleFormDataList = formDataCoreService.listBySimpleBusinessIds( + stepComponentIds, + FormDataBusinessTypeEnum.STEP_COMPONENT.getType(), + BusinessTypeClassifyEnum.STEP_COMPONENT.getType() + ); + formDataDoList.addAll(moduleFormDataList); + } + } + if (CollectionUtils.isEmpty(formDataDoList)) { + return new ArrayList<>(0); + } + Set formDataIds = formDataDoList.stream().map(FormDataDo::getId).collect(Collectors.toSet()); + List outputDataDoList = outputDataCoreService.listSimpleByFormDataIdsAndType(formDataIds, + OutPutDataTypeEnum.GAME.getType(), + DeleteStatusEnum.NOT_DELETE.getValue()); + Map formDataIdMap = + outputDataDoList + .stream() + .collect(Collectors.toMap(OutputDataDo::getFormDataId, Function.identity(), (k1, k2) -> k2)); + List resultList = formDataDoList.stream().map(e -> { + FormAndDataCacheBo bo = new FormAndDataCacheBo(); + bo.setBusinessId(e.getBusinessId()); + bo.setBusinessType(e.getBusinessType()); + bo.setBusinessTypeClassify(e.getBusinessTypeClassify()); + bo.setOutputDataType(OutPutDataTypeEnum.GAME.getType()); + bo.setOutDataJson(Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getUrl + ).orElse(StringUtils.EMPTY)); + OutputDataDo selectedOutput = formDataIdMap.get(e.getId()); + if (selectedOutput != null) { + bo.setSegmentSourceFormDataId(selectedOutput.getFormDataId()); + bo.setSegmentSourcePayloadHash(selectedOutput.getSegmentSourcePayloadHash()); + } + String outPutData = Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getOutputData) + .orElse(StringUtils.EMPTY); + if (StringUtils.isNotBlank(outPutData)) { + try { + bo.setOutDataJsonObj(JSON.parseObject(outPutData)); + } catch (Exception exception) { + log.error("[FormDataBizServiceImpl.listFormAndDataCacheBo] outPutData not a standard json,id:{}", + e.getId()); + bo.setOutDataJsonObj(null); + } + } + return bo; + }).collect(Collectors.toList()); + return resultList; + } + + @Override + public List listFormDataCacheBo(Set cacheKeys) { + if (CollectionUtils.isEmpty(cacheKeys)) { + return new ArrayList<>(0); + } + Map>> cacheKeyMap = cacheKeys.stream() + .collect( + Collectors.groupingBy( + FormDataCacheKey::getBusinessType, + Collectors.groupingBy(FormDataCacheKey::getBusinessTypeClassify, + Collectors.mapping(FormDataCacheKey::getBusinessId, Collectors.toSet())) + ) + ); + List formDataDoList = new ArrayList<>(); + // 获取标准题相关报文数据 + if (cacheKeyMap.containsKey(FormDataBusinessTypeEnum.STANDARD_QUESTION.getType())) { + Map> businessTypeClassifyMap = + cacheKeyMap.get(FormDataBusinessTypeEnum.STANDARD_QUESTION.getType()); + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType())) { + Set contentNormConfigIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()); + List contentNormFormDataList = formDataCoreService.listByBusinessIds( + contentNormConfigIds, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType() + ); + if (CollectionUtils.isNotEmpty(contentNormFormDataList)) { + formDataDoList.addAll(contentNormFormDataList); + } + } + if (businessTypeClassifyMap.containsKey(BusinessTypeClassifyEnum.EXPLAINS.getType())) { + Set questionIds = + businessTypeClassifyMap.get(BusinessTypeClassifyEnum.EXPLAINS.getType()); + List questionExplainFormDataList = formDataCoreService.listByBusinessIds( + questionIds, + FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(), + BusinessTypeClassifyEnum.EXPLAINS.getType() + ); + if (CollectionUtils.isNotEmpty(questionExplainFormDataList)) { + formDataDoList.addAll(questionExplainFormDataList); + } + } + } + else { + Set stepComponentIds = + cacheKeys.stream().map(FormDataCacheKey::getBusinessId).collect(Collectors.toSet()); + List stepComponentList = formDataCoreService.listByBusinessIds( + stepComponentIds, + FormDataBusinessTypeEnum.STEP_COMPONENT.getType(), + BusinessTypeClassifyEnum.STEP_COMPONENT.getType() + ); + if (CollectionUtils.isNotEmpty(stepComponentList)) { + formDataDoList.addAll(stepComponentList); + } + } + List formDataCacheBoList = formDataDoList.stream().map(e -> { + FormDataCacheBo cacheBo = new FormDataCacheBo(); + cacheBo.setBusinessId(e.getBusinessId()); + cacheBo.setBusinessType(e.getBusinessType()); + cacheBo.setBusinessTypeClassify(e.getBusinessTypeClassify()); + cacheBo.setFormData(e.getFormData()); + return cacheBo; + }).collect(Collectors.toList()); + + return formDataCacheBoList; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void batchSaveFormData(List formDataDoList, Boolean needSnapshot, MetaData metaData) { + formDataCoreService.saveOrUpdateBatch(formDataDoList); + SnapshotBizService snapshotBizService = SpringUtils.getBean(SnapshotBizService.class); + if (needSnapshot) { + snapshotBizService.batchSaveSnapshotByCategory( + metaData.getSnapshotCategoryEnum(), + metaData.getSnapshotCategoryId(), + FormDataDao.class, + formDataDoList); + } + } + + @Override + public void schemaDataSync(SchemaDataSyncBo bo, LoginUserBo userBo) { + FormSchemaBizService formSchemaBizService = SpringUtils.getBean(FormSchemaBizService.class); + FormSchemaDo schemaDo = formSchemaBizService.getFormSchema(bo.getBusinessId(), bo.getBusinessType(), bo.getBusinessTypeClassify()); + //表单报文数据 + JSON formData = buildFormData(schemaDo, bo.getProductionItemData()); + + FormDataSaveBo saveBo = new FormDataSaveBo(); + saveBo.setBusinessId(bo.getBusinessId()); + saveBo.setIsAllConfig(Constants.ONE); + saveBo.setSchemaId(schemaDo.getId()); + saveBo.setFormData(formData); + saveBo.setBusinessType(bo.getBusinessType()); + saveBo.setBusinessTypeClassify(bo.getBusinessTypeClassify()); + saveBo.setMetaData(bo.getMetaData()); + FormDateSaveResultBo resultBo = formDataSave(saveBo, userBo); + if (Constants.RESP_CODE_ERROR.equals(resultBo.getCode())) { + Object errMessage = resultBo.getErrMessage(); + throw BusinessException.exception(JSON.toJSONString(errMessage)); + } + } + + private Long getSchemaRelationId(Long businessId, Integer businessType, Integer businessTypeClassify) { + if (FormDataBusinessTypeEnum.STEP_COMPONENT.getType().equals(businessType)) { + StepComponentDo stepComponentDo = + stepComponentCoreService.getById(businessId, DeleteStatusEnum.NOT_DELETE.getValue()); + return Optional.ofNullable(stepComponentDo).map(StepComponentDo::getComponentId) + .orElse(Constants.DEFAULT_ID); + } + return Constants.DEFAULT_ID; + } + + @Valid + public JSON buildFormData(FormSchemaDo formSchemaDo, List businessItemBoList) { + Map pathMap = SchemaUtils.getPathMap(formSchemaDo.getFormSchema()); + JSON mergeJson = new JSONObject(); + businessItemBoList.forEach(item -> { + if (pathMap.containsKey(item.getParamBo().getPathKey())) { + JsonNode jsonNode = pathMap.get(item.getParamBo().getPathKey()); + JSONPath.set( + mergeJson, item.getParamBo().getPathKey(), + SchemaUtils.convertItemToJson(jsonNode, item.getConfigValue()) + ); + } + }); + JsonNode schemaNode = + JsonSchemaUtil.strToJsonNode(JSON.parseObject(formSchemaDo.getFormSchema()).toJSONString()); + //根据schema去除多余字段 + JSON json = SchemaUtils.fillWithNullsAccordingToSchema(schemaNode, mergeJson, true, true); + return json; + } + +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/impl/schema/process/oss/GenerateOutPutDataService.java @@ -0,0 +1,218 @@ +package cn.jojo.cc.biz.service.impl.schema.process.oss; + +import cn.jojo.cc.biz.model.bo.schema.ServiceContextBo; +import cn.jojo.cc.biz.service.component.FileUpload; +import cn.jojo.cc.biz.service.component.UploadService; +import cn.jojo.cc.biz.service.impl.schema.process.context.FormDataSaveContext; +import cn.jojo.cc.biz.service.impl.schema.process.context.ServiceContext; +import cn.jojo.cc.biz.service.impl.schema.process.helper.SchemaSaveContextHelper; +import cn.jojo.cc.biz.service.media.MediaTaskBizService; +import cn.jojo.cc.biz.service.schema.OutputDataBizService; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaExtractDataUtil; +import cn.jojo.cc.biz.utils.jsonschema.JsonSchemaUtil; +import cn.jojo.cc.common.constants.Constants; +import cn.jojo.cc.core.domain.schema.common.data.FormDataDo; +import cn.jojo.cc.core.domain.schema.common.data.OutputDataDo; +import cn.jojo.cc.core.domain.schema.common.schema.OutputDataSchemaDo; +import cn.jojo.cc.common.enums.question.OutPutDataTypeEnum; +import cn.jojo.cc.core.service.schema.OutputDataCoreService; +import cn.jojo.cc.biz.utils.VideoMidThreadContext; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONObject; +import com.alibaba.fastjson.serializer.SerializerFeature; +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import jakarta.annotation.Resource; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 描述: + * + * @author: ZHR + * @data: 2024-03-15 + */ +@Slf4j +@Service +public class GenerateOutPutDataService { + + + @Resource + private OutputDataCoreService outputDataCoreService; + + @Resource + private UploadService uploadService; + + private static final String JSON_NAME_SUFFIX = "_schema_data.json"; + + @Resource + private OutputDataBizService outputDataBizService; + + @Resource + private SchemaSaveContextHelper schemaSaveContextHelper; + + @Resource + private MediaTaskBizService mediaTaskBizService; + + /** + * 生成数据 + * + * @param context + */ + @Transactional(rollbackFor = Exception.class) + public void genOutPutData(FormDataSaveContext context) { + log.info("genOutPutData start, formDataId: {}", context.getFormDataDo().getId()); + //快照标记 + schemaSaveContextHelper.markSnapshotFlag(context); + ServiceContextBo originContext = ServiceContext.getContext(); + // 设置线程上下文(供 fillVideoMid 三级查询使用),标准题无环节ID,VOD查询跳过 + VideoMidThreadContext.set(new VideoMidThreadContext.VideoMidInfo(null, null)); + try { + ServiceContext.setContext(new ServiceContextBo(context)); + FormDataDo formDataDo = context.getFormDataDo(); + // 在任何schema转换/media增强前采集,且只为标准题内容附加来源元数据。 + String segmentSourceHash = java.util.Objects.equals(formDataDo.getBusinessType(), + cn.jojo.cc.common.enums.question.FormDataBusinessTypeEnum.STANDARD_QUESTION.getType()) + && java.util.Objects.equals(formDataDo.getBusinessTypeClassify(), + cn.jojo.cc.common.enums.question.BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType()) + ? cn.jojo.cc.biz.service.english.segment.SegmentPayloadIdentity.hash(formDataDo.getFormData()) : null; + Long formDataId = context.getFormDataDo().getId(); + Long formSchemaId = context.getFormSchemaId(); + // 获取数据库依据存在的配置数据 + List dbOutDataList = outputDataCoreService.list(formDataId); + Map dbOutDataMap = dbOutDataList.stream() + .collect( + Collectors.toMap( + OutputDataDo::getOutputDataSchemaId, + Function.identity() + ) + ); + List outputDataSchemaList = context.getOutputDataSchemaList(); + //过滤报告schema + outputDataSchemaList = outputDataSchemaList.stream().filter(e -> Objects.equals(e.getOutputDataType(), + OutPutDataTypeEnum.GAME.getType())).collect(Collectors.toList()); + if (CollectionUtils.isEmpty(outputDataSchemaList)) { + return; + } + List outDataList = new ArrayList<>(); + for (OutputDataSchemaDo dataSchema : outputDataSchemaList) { + String schemaJson = dataSchema.getOutputDataSchema(); + JsonNode schemaNode = JsonSchemaUtil.strToJsonNode(schemaJson); + JSON dataJson = JsonSchemaExtractDataUtil.buildGameSchemaData( + formDataDo.getFormData().toJSONString(), schemaNode, true + ); + // 服务端生成时动态写入视频 mid(与环节/题 JSON 同一扫描逻辑) + // mid 注入为增强逻辑,查询异常时跳过注入,不阻断输出数据生成落库 + if (dataJson instanceof JSONObject jsonObject) { + try { + mediaTaskBizService.fillVideoMid(jsonObject); + } catch (Exception e) { + log.warn("outputData 注入 video mid 失败,跳过,formDataId: {}, schemaId: {}", + formDataId, dataSchema.getId(), e); + } + } + String strJson = JSON.toJSONString(dataJson, SerializerFeature.WriteMapNullValue, SerializerFeature.SortField); + OutputDataDo outputDataDo = Optional.ofNullable(dbOutDataMap.get(dataSchema.getId())).orElse(new OutputDataDo()); + outputDataDo.setOutputData(strJson); + if (segmentSourceHash != null) { outputDataDo.setSegmentSourcePayloadHash(segmentSourceHash); } + outputDataDo.fillRequired(context.getUserBo(), System.currentTimeMillis(), Objects.isNull(outputDataDo.getId())); + if (Objects.isNull(outputDataDo.getId())) { + outputDataDo.setFormDataId(formDataId); + outputDataDo.setFormSchemaId(formSchemaId); + outputDataDo.setOutputDataType(dataSchema.getOutputDataType()); + outputDataDo.setOutputDataSchemaId(dataSchema.getId()); + } + String fileName = String.join( + Constants.DEFAULT_DELIMITER, + String.valueOf(formDataId), + String.valueOf(dataSchema.getOutputDataType()) + ); + FileUpload fileUpload = uploadService.uploadBytes( + Constants.TEXT_PLAIN_UTF_8, fileName + JSON_NAME_SUFFIX, + strJson.getBytes() + ); + if (!fileUpload.isCheckResult()) { + outputDataDo.setUrl(""); + } else { + outputDataDo.setUrl(fileUpload.getStorageUrl()); + } + outDataList.add(outputDataDo); + } + outputDataCoreService.saveOrUpdateBatch(outDataList); + outputDataBizService.batchSaveOutPutData(outDataList, + context.getNeedSnapshot(), + context.getBusinessDataMetaData() + ); + } finally { + VideoMidThreadContext.remove(); + ServiceContext.removeContext(); + if (Objects.nonNull(originContext)) { + ServiceContext.setContext(originContext); + } + } + + } + + /** + * 回填指定表单存量 output_data 的视频 mid(不重建结构,仅注入 {字段名}_mid 后落库并更新 OSS 文件) + *

+ * 适用于无 schema relation 且 form_schema_id 未落库的表单(如题公共 classify=3), + * 回调刷新无法走 genOutPutData 的 schema 提取,直接对已生成的 output_data 增量补 mid。 + * 单个文档处理异常跳过不阻断;无变更时不落库,重复执行幂等 + * + * @param formDataId 表单数据 id + * @return 是否有更新 + */ + @Transactional(rollbackFor = Exception.class) + public boolean backfillOutputDataMid(Long formDataId) { + List outputDataDos = outputDataCoreService.list(formDataId); + if (CollectionUtils.isEmpty(outputDataDos)) { + return false; + } + // 标准题无环节ID,VOD查询跳过(与 genOutPutData 线程上下文语义一致) + VideoMidThreadContext.set(new VideoMidThreadContext.VideoMidInfo(null, null)); + try { + List updatedDos = new ArrayList<>(); + for (OutputDataDo outputDataDo : outputDataDos) { + String outputData = outputDataDo.getOutputData(); + if (StringUtils.isBlank(outputData)) { + continue; + } + try { + JSONObject jsonObject = JSON.parseObject(outputData); + if (Objects.isNull(jsonObject) || !mediaTaskBizService.fillVideoMid(jsonObject)) { + continue; + } + String strJson = JSON.toJSONString(jsonObject, SerializerFeature.WriteMapNullValue, SerializerFeature.SortField); + outputDataDo.setOutputData(strJson); + FileUpload fileUpload = uploadService.uploadBytes(Constants.TEXT_PLAIN_UTF_8, + String.join(Constants.DEFAULT_DELIMITER, String.valueOf(formDataId), + String.valueOf(outputDataDo.getOutputDataType())) + JSON_NAME_SUFFIX, + strJson.getBytes()); + outputDataDo.setUrl(fileUpload.isCheckResult() ? fileUpload.getStorageUrl() : ""); + updatedDos.add(outputDataDo); + } catch (Exception e) { + log.warn("backfillOutputDataMid 回填output_data的mid失败, formDataId={}, outputDataId={}", + formDataId, outputDataDo.getId(), e); + } + } + if (CollectionUtils.isNotEmpty(updatedDos)) { + outputDataCoreService.saveOrUpdateBatch(updatedDos); + } + return CollectionUtils.isNotEmpty(updatedDos); + } finally { + VideoMidThreadContext.remove(); + } + } + +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/DefaultSegmentTextExtractorTest.java @@ -0,0 +1,233 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.common.enums.dict.QuestionTypeEnum; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * 富文本抽取规则测试。 + */ +class DefaultSegmentTextExtractorTest { + + private static final String PROVIDED_RICH_TEXT = + "Betty is my good friend. " + + "She's from the USA. We are \\checkmark classmates. " + + "We're in_{————} Class① Two, Grade Six" + + "(\\quad\\quad\\quad\\quad). She 发的\\quad发is " + + "tall and thin. She has lovely eyes and short hair. " + + "She is hard-working and clever. " + + "She always gets good grades (成绩). She likes winter because she can make snowmen in winter. " + + "Her favourite colours are yellow and blue, so she usually wears a yellow shirt and blue trousers. " + + "We often do homework together on the weekend. We will be good friends forever."; + + private final DefaultSegmentTextExtractor extractor = new DefaultSegmentTextExtractor(); + + @Test + void extractPlainTextKeepsIpaTokenForSharedTokenizer() { + String richText = PROVIDED_RICH_TEXT + + " ˈfəʊnəm low-temperature"; + + String plainText = extractor.extractPlainText(richText); + + assertTrue(plainText.contains("Betty is my good friend.")); + assertTrue(plainText.contains("She's from the USA.")); + assertTrue(plainText.contains("We're in Class")); + assertTrue(plainText.contains("She 发的")); + assertFalse(plainText.contains("发的发is")); + assertTrue(plainText.contains("is tall and thin.")); + assertTrue(plainText.contains("hard-working and clever.")); + assertTrue(plainText.contains("(成绩)")); + assertTrue(plainText.contains("low-temperature")); + assertFalse(plainText.contains("<")); + assertFalse(plainText.contains("formula")); + assertFalse(plainText.contains("checkmark")); + assertFalse(plainText.contains("quad")); + assertFalse(plainText.contains("x.png")); + assertTrue(plainText.contains("ˈfəʊnəm")); + assertFalse(cn.jojo.cc.common.utils.EnglishWords.tokenize(plainText).stream() + .anyMatch(token -> List.of("f", "n", "m").contains(token.text()))); + } + + /** + * 题目富文本不存在斜杠包裹的音标,因此不得按“成对斜杠”猜测并删除内容: + * and/or、his/her、URL 与日期都属于必须保留的正文。 + */ + @Test + void extractPlainTextKeepsSlashContainingBodyText() { + String plainText = extractor.extractPlainText( + "Use and/or, his/her. Visit https://x.com/a/b on 12/25/2026."); + + assertTrue(plainText.contains("and/or")); + assertTrue(plainText.contains("his/her")); + assertTrue(plainText.contains("https://x.com/a/b")); + assertTrue(plainText.contains("12/25/2026")); + } + + @Test + void extractPlainTextKeepsChineseAsSeparatorAndDecodesEntities() { + String plainText = extractor.extractPlainText( + "She is my good friend. 她是我的好朋友。 She's hard-working."); + + assertTrue(plainText.contains("She is my good friend.")); + assertTrue(plainText.contains("她是我的好朋友")); + assertTrue(plainText.contains("She's hard-working.")); + } + + @Test + void extractRichTextsReturnsEmptyWhenNoTextCanBeResolved() { + // 必须显式给定白名单:不给出则用例会卡在题型关卡就返回空,看似通过实际没验证“无正文可解析”。 + extractor.setQuestionTypeWhitelist(Set.of(30001)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_CHOICE.getCode()); + + assertTrue(extractor.extractRichTexts(question).isEmpty()); + } + + @Test + void extractRichTextsReturnsEmptyWhenQuestionTypeOutsideWhitelist() { + extractor.setQuestionTypeWhitelist(Set.of(30001)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_FILL.getCode()); + + assertTrue(extractor.extractRichTexts(question).isEmpty()); + } + + /** + * 显式空名单 = 关闭分词:任何题型都不抽取,不得回退到内建默认名单。 + */ + @Test + void extractRichTextsReturnsNothingWhenWhitelistExplicitlyEmpty() { + extractor.setQuestionTypeWhitelist(Set.of()); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_CHOICE.getCode()); + + assertTrue(extractor.extractRichTexts(question).isEmpty()); + } + + @Test + void extractRichTextsRejectsNullAndUnsavedQuestion() { + extractor.setQuestionTypeWhitelist(Set.of(30001)); + + assertTrue(extractor.extractRichTexts(null).isEmpty()); + assertTrue(extractor.extractRichTexts(new QuestionDo()).isEmpty()); + } + + @Test + void extractRichTextsForChoiceReturnsEachMaterialItemSeparately() { + extractor.setQuestionTypeWhitelist(Set.of(30001)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_CHOICE.getCode()); + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"material\":{\"materialContent\":[" + + "{\"text\":{\"unity\":\"look\"}}," + + "{\"text\":{\"unity\":\"up\"}}" + + "]}" + + "}}"); + + List richTexts = extractor.extractRichTexts(question); + + assertEquals(List.of("look", "up"), richTexts); + } + + @Test + void extractRichTextsForChoiceUsesTextMaterialOnly() { + extractor.setQuestionTypeWhitelist(Set.of(30001)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_CHOICE.getCode()); + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"material\":{\"materialContent\":[" + + "{\"text\":{\"unity\":\"The material text\"}}" + + "]}," + + "\"answers\":[{\"text\":{\"unity\":\"Option text\"}}]" + + "}}"); + + assertEquals(List.of("The material text"), extractor.extractRichTexts(question)); + } + + @Test + void extractRichTextsForFillUsesAnswerAreaText() { + extractor.setQuestionTypeWhitelist(Set.of(30002)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_FILL.getCode()); + // answerArea 在 Schema 里是 object(不是数组),正文在 textAnswerAreaContent。 + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"material\":{\"materialContent\":[" + + "{\"text\":{\"unity\":\"Material text\"}}" + + "]}," + + "\"answerArea\":{\"answerAreaType\":0," + + "\"textAnswerAreaContent\":{\"unity\":\"answer one\"}}" + + "}}"); + + assertEquals(List.of("answer one"), extractor.extractRichTexts(question)); + } + + @Test + void extractRichTextsForInputUsesAnswerAreaContentNotDetermineInfo() { + extractor.setQuestionTypeWhitelist(Set.of(30004)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_INPUT.getCode()); + // 输入题正文同样在 questionInfo.answerArea.textAnswerAreaContent; + // determineInfo 里的是判定答案,不属于要划词的学生可见文字。 + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"answerArea\":{\"answerAreaType\":0," + + "\"textAnswerAreaContent\":{\"unity\":\"input material\"}}" + + "}," + + "\"determineInfo\":{\"answerContent\":[{\"value\":{\"unity\":\"judged answer\"}}]}}"); + + assertEquals(List.of("input material"), extractor.extractRichTexts(question)); + } + + @Test + void extractRichTextsForClozeUsesAnswerAreaText() { + extractor.setQuestionTypeWhitelist(Set.of(30011)); + QuestionDo question = question(1L, QuestionTypeEnum.STAND_CLOZE_QUESTION.getCode()); + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"answerArea\":{\"textAnswerAreaContent\":{\"unity\":\"cloze answer\"}}" + + "}}"); + + assertEquals(List.of("cloze answer"), extractor.extractRichTexts(question)); + } + + @Test + void extractRichTextsForFillToleratesArrayShapedAnswerArea() { + // 空格分类类组件把作答区做成数组,不能因类型不符抛 JSONObject→JSONArray 异常导致整题失败。 + extractor.setQuestionTypeWhitelist(Set.of(30002)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_FILL.getCode()); + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"answerArea\":[{\"classifyName\":{\"unity\":\"blank name\"}}]" + + "}}"); + + assertEquals(List.of("blank name"), extractor.extractRichTexts(question)); + } + + @Test + void extractRichTextsForCombinationIsUnsupportedThisPhase() { + extractor.setQuestionTypeWhitelist(Set.of(30005)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_COMBINATION.getCode()); + extractor.setFormDataResolver(q -> "{\"questionInfo\":{" + + "\"questionStem\":{\"text\":{\"unity\":\"parent material\"}}" + + "}}"); + + assertTrue(extractor.extractRichTexts(question).isEmpty()); + } + + @Test + void extractRichTextsFallsBackToHistoricalQuestionContent() { + extractor.setQuestionTypeWhitelist(Set.of(30001)); + QuestionDo question = question(1L, QuestionTypeEnum.GENERAL_CHOICE.getCode()); + extractor.setFormDataResolver(q -> null); + extractor.setHistoricalContentResolver(q -> "{\"question_info\":{" + + "\"text\":\"historical material\"" + + "}}"); + + assertEquals(List.of("historical material"), extractor.extractRichTexts(question)); + } + + private QuestionDo question(Long id, Integer questionType) { + QuestionDo question = new QuestionDo(); + question.setId(id); + question.setQuestionKey("Q_" + id); + question.setQuestionType(questionType); + return question; + } +} --- a/application/src/test/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizServiceTest.java +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizServiceTest.java @@ -52,36 +52,20 @@ @InjectMocks private EnglishSegmentDeliveryBizService bizService; @Mock - private cn.jojo.cc.core.service.question.library.QuestionCoreService questionCoreService; - - @Mock - private SegmentContentSnapshotService segmentContentSnapshotService; - - @Mock private QuestionSegmentCoreService questionSegmentCoreService; @Mock private SegmentMatchIdResolver segmentMatchIdResolver; @Mock private EnglishDictConfig englishDictConfig; @BeforeEach void passThroughResolver() { - lenient().when(questionCoreService.listByKeys(anyCollection())).thenAnswer(i -> { - java.util.Collection keys = i.getArgument(0); - java.util.List rows = new java.util.ArrayList<>(); - for (String key : keys) { - var q = new cn.jojo.cc.core.domain.question.library.QuestionDo(); q.setId(1L); q.setQuestionKey(key); rows.add(q); - } - return rows; - }); - lenient().when(segmentContentSnapshotService.readTexts(any())).thenReturn(Map.of(1, List.of("good"))); - lenient().when(segmentContentSnapshotService.fingerprint(any())).thenReturn("current-md5"); // 默认开关打开:Mockito 未打桩时布尔默认 false,会令存量用例静默走关闭分支(需 lenient:关开关用例不消费本桩)。 lenient().when(englishDictConfig.isSegmentEnabled()).thenReturn(true); // 解析行为由 SegmentMatchIdResolverTest 负责;这里只让结果原样透出,才能看清装配做了什么。 lenient().when(segmentMatchIdResolver.resolveIds(any(), any())) .thenAnswer(invocation -> invocation.getArgument(1)); @@ -96,22 +80,28 @@ void segmentDisabledReturnsEmptyWithoutTouchingDb() { // 回滚契约:总开关关闭 = 一键止血,不查库、整批不下发,存量分词行也不透出。 when(englishDictConfig.isSegmentEnabled()).thenReturn(false); Map result = - bizService.resolveByQuestionKeys(new HashSet<>(Collections.singletonList("Q_1"))); + resolve(new HashSet<>(Collections.singletonList("Q_1"))); assertTrue(result.isEmpty()); verify(questionSegmentCoreService, never()).listByQuestionKeys(anyCollection()); } private QuestionSegmentDo row(String questionKey, String segments) { QuestionSegmentDo row = new QuestionSegmentDo(); row.setQuestionKey(questionKey); row.setContentType(1); row.setSourceTextMd5("current-md5"); - row.setSegments(segments); + try { + var json = com.alibaba.fastjson.JSON.parseObject(segments); + json.put("sourcePayloadHash", "form-v1:00000000000000000000000000000000"); + json.put("sourceFormDataId", 7L); json.put("sourceContentConfigId", 8L); + json.put("sourceOutputPayloadHash", "output-v1:00000000000000000000000000000000"); + row.setSegments(json.toJSONString()); + } catch (Exception e) { row.setSegments(segments); } // 真实表里 segment_status NOT NULL DEFAULT 0,成功行不下状态会静默走“非成功不下发”分支。 row.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); return row; } @@ -121,31 +111,31 @@ // 下发它会让学生点在错位置上(按顺序定位)——宁可本题不可点,也不给旧坐标。 QuestionSegmentDo failed = row("q1", NORMAL_ROW); failed.setSegmentStatus(SegmentStatusEnum.FAILED.getCode()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())).thenReturn(List.of(failed)); - Map result = bizService.resolveByQuestionKeys(Set.of("q1")); + Map result = resolve(Set.of("q1")); assertTrue(result.isEmpty()); verify(segmentMatchIdResolver, never()).resolveIds(any(), any()); } @Test void emptyOrNullKeysQueryNothing() { - assertTrue(bizService.resolveByQuestionKeys(null).isEmpty()); - assertTrue(bizService.resolveByQuestionKeys(Collections.emptySet()).isEmpty()); + assertTrue(resolve(null).isEmpty()); + assertTrue(resolve(Collections.emptySet()).isEmpty()); verify(questionSegmentCoreService, never()).listByQuestionKeys(anyCollection()); } @Test void prodThreadResolvesAgainstProdDictionary() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(List.of(row("q1", NORMAL_ROW))); - Map result = bizService.resolveByQuestionKeys(Set.of("q1")); + Map result = resolve(Set.of("q1")); assertEquals(1, result.size()); verify(segmentMatchIdResolver).resolveIds(eq(DataBaseTypeEnum.PROD), any()); } @@ -153,22 +143,22 @@ void teachingThreadResolvesAgainstJyDictionary() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.DEFAULT.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(List.of(row("q1", NORMAL_ROW))); - bizService.resolveByQuestionKeys(Set.of("q1")); + resolve(Set.of("q1")); verify(segmentMatchIdResolver).resolveIds(eq(DataBaseTypeEnum.TEACHING), any()); } @Test @SuppressWarnings("unchecked") void blankAndDuplicateKeysAreCollapsedBeforeQuery() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())).thenReturn(Collections.emptyList()); - bizService.resolveByQuestionKeys(new HashSet<>(Arrays.asList("q1", "q1", " ", null))); + resolve(new HashSet<>(Arrays.asList("q1", "q1", " ", null))); ArgumentCaptor captor = ArgumentCaptor.forClass(Collection.class); verify(questionSegmentCoreService).listByQuestionKeys(captor.capture()); Collection sent = captor.getValue(); assertEquals(1, sent.size()); @@ -179,11 +169,11 @@ void rowsWithoutSnapshotAreAbsentInsteadOfEmptySequence() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(List.of(row("q1", null), row("q2", " "))); - Map result = bizService.resolveByQuestionKeys(Set.of("q1", "q2")); + Map result = resolve(Set.of("q1", "q2")); // “没分过词”与“分过词但一个没命中”是两回事:前者必须缺席,让调用方保持 segments=null。 assertTrue(result.isEmpty()); verify(segmentMatchIdResolver, never()).resolveIds(any(), any()); } @@ -192,11 +182,11 @@ void emptyMatchesSequenceIsPassedThroughAsLegitimateResult() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(List.of(row("q1", "{\"matches\":[]}"))); - Map result = bizService.resolveByQuestionKeys(Set.of("q1")); + Map result = resolve(Set.of("q1")); assertNotNull(result.get("q1")); assertTrue(result.get("q1").getMatches().isEmpty()); } @@ -204,11 +194,11 @@ void dirtyJsonDropsOnlyThatQuestion() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(Arrays.asList(row("bad", "not-a-json"), row("good", NORMAL_ROW), row(null, NORMAL_ROW))); - Map result = bizService.resolveByQuestionKeys(Set.of("bad", "good")); + Map result = resolve(Set.of("bad", "good")); // 一行坏数据不能让整批下发失败,也不能凭空造出一个 null key 的条目。 assertEquals(1, result.size()); assertNotNull(result.get("good")); assertNull(result.get("bad")); @@ -221,11 +211,11 @@ .thenReturn(Arrays.asList(row("q1", NORMAL_ROW), row("q2", NORMAL_ROW))); when(segmentMatchIdResolver.resolveIds(any(), any())) .thenThrow(new IllegalStateException("dict db down")) .thenReturn(new EnglishSegmentDto()); - Map result = bizService.resolveByQuestionKeys(Set.of("q1", "q2")); + Map result = resolve(Set.of("q1", "q2")); assertEquals(1, result.size()); assertNotNull(result.get("q2")); } @@ -237,11 +227,11 @@ when(questionSegmentCoreService.listByQuestionKeys(anyCollection())).thenReturn(List.of(row("q1", "{\"matches\":[{\"text\":\"good\",\"type\":\"word\"}," + "{\"text\":\"artemisinin\",\"type\":\"word\",\"wordId\":-1}," + "{\"text\":\"good\",\"type\":\"word\"}]}"))); - Map result = bizService.resolveByQuestionKeys(Set.of("q1")); + Map result = resolve(Set.of("q1")); List matches = result.get("q1").getMatches(); assertEquals(3, matches.size()); assertEquals("good", matches.get(0).getText()); assertEquals("artemisinin", matches.get(1).getText()); @@ -257,30 +247,34 @@ void legacyHistoryIdNeverReachesCallerWithoutResolver() { MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) .thenReturn(List.of(row("q1", LEGACY_ROW))); - bizService.resolveByQuestionKeys(Set.of("q1")); + resolve(Set.of("q1")); // 交给解析器的必须是剥掉 ID 的快照:库存里那个 wordId=999 不得绕过读时解析直接下发。 ArgumentCaptor sent = ArgumentCaptor.forClass(EnglishSegmentDto.class); verify(segmentMatchIdResolver).resolveIds(eq(DataBaseTypeEnum.PROD), sent.capture()); assertEquals("good", sent.getValue().getMatches().get(0).getText()); assertNull(sent.getValue().getMatches().get(0).getWordId()); } @Test - void changedBodyAtSameAuditRevisionRejectsStoredNormalSequence() { + void selectedV1OutputCannotReceiveV2StoredSegments() { QuestionSegmentDo stored = row("q1", NORMAL_ROW); - stored.setQuestionRevision(1000L); when(questionSegmentCoreService.listByQuestionKeys(anyCollection())).thenReturn(List.of(stored)); - when(segmentContentSnapshotService.fingerprint(any())).thenReturn("new-body-md5"); - assertTrue(bizService.resolveByQuestionKeys(Set.of("q1")).isEmpty()); - verify(segmentMatchIdResolver, never()).resolveIds(any(), any()); - } - - @Test - void unavailableCurrentContentDropsSegmentsWithoutThrowing() { - when(questionSegmentCoreService.listByQuestionKeys(anyCollection())).thenReturn(List.of(row("q1", NORMAL_ROW))); - when(segmentContentSnapshotService.readTexts(any())).thenThrow(new IllegalStateException("form table down")); - assertTrue(bizService.resolveByQuestionKeys(Set.of("q1")).isEmpty()); + var v1 = new SelectedSegmentOutput(DataBaseTypeEnum.TEACHING, "/v1.json", + new SegmentPayloadIdentity("form-v1:11111111111111111111111111111111", 7L, 8L, "output-v1:11111111111111111111111111111111")); + assertTrue(bizService.resolveByQuestionKeys(Set.of("q1"), Map.of("q1", v1)).isEmpty()); + } + + private Map resolve(Set keys) { + Map selected = new java.util.HashMap<>(); + if (keys != null) { + for (String key : keys) { + selected.put(key, new SelectedSegmentOutput( + MultiDataSourceContextHolder.isJYDatasource() ? DataBaseTypeEnum.TEACHING : DataBaseTypeEnum.PROD, + "/v2.json", new SegmentPayloadIdentity("form-v1:00000000000000000000000000000000", 7L, 8L, "output-v1:00000000000000000000000000000000"))); + } + } + return bizService.resolveByQuestionKeys(keys, selected); } } --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/SegmentResponseIdentityRegressionTest.java @@ -0,0 +1,62 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.model.cache.FormAndDataCacheBo; +import cn.jojo.cc.biz.model.bo.english.SegmentSnapshot; +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.DataSourceEnum; +import cn.jojo.cc.core.multidatasource.MultiDataSourceContextHolder; +import cn.jojo.cc.core.domain.english.QuestionSegmentDo; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import com.alibaba.fastjson.JSON; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class SegmentResponseIdentityRegressionTest { + @Test void canonicalPreservesNullAndArrayOrderButIgnoresObjectKeyOrder() { + assertEquals(SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":1,\"b\":null}")), + SegmentPayloadIdentity.hash(JSON.parseObject("{\"b\":null,\"a\":1}"))); + assertNotEquals(SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":1,\"b\":null}")), + SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":1}"))); + assertNotEquals(SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":[1,2]}")), + SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":[2,1]}"))); + } + + @Test void selectedV1ThenNewV2SequenceCannotBeMixedWhileSameV2StillWorks() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.DEFAULT.getValue()); + try { + var bo = new FormAndDataCacheBo(); bo.setBusinessId(8L); bo.setSegmentSourceFormDataId(7L); + var input1 = JSON.parseObject("{\"body\":\"v1\"}"); + var input2 = JSON.parseObject("{\"body\":\"v2\"}"); + bo.setSegmentSourcePayloadHash(SegmentPayloadIdentity.hash(input1)); + bo.setOutDataJson("/v1.json"); bo.setOutDataJsonObj(input1); + var selectedV1 = SelectedSegmentOutput.capture(DataBaseTypeEnum.TEACHING, 8L, bo, "/v1.json"); + // 先冻结请求v1,再将缓存/库存推进到v2,审计时间不参与证明。 + bo.setSegmentSourcePayloadHash(SegmentPayloadIdentity.hash(input2)); + bo.setOutDataJson("/v2.json"); bo.setOutDataJsonObj(input2); + var selectedV2 = SelectedSegmentOutput.capture(DataBaseTypeEnum.TEACHING, 8L, bo, "/v2.json"); + var snapshot = new SegmentSnapshot(); snapshot.setMatches(List.of()); + snapshot.setSourcePayloadHash(selectedV2.identity().payloadHash()); + snapshot.setSourceFormDataId(7L); snapshot.setSourceContentConfigId(8L); + snapshot.setSourceOutputPayloadHash(selectedV2.identity().outputPayloadHash()); + var row = new QuestionSegmentDo(); row.setQuestionKey("q1"); row.setSegmentStatus(0); row.setContentType(1); + row.setSegments(JSON.toJSONString(snapshot)); + var core = mock(QuestionSegmentCoreService.class); + when(core.listByQuestionKeys(anyCollection())).thenReturn(List.of(row)); + var config = mock(EnglishDictConfig.class); when(config.isSegmentEnabled()).thenReturn(true); + var resolver = mock(SegmentMatchIdResolver.class); + when(resolver.resolveIds(any(), any())).thenAnswer(i -> i.getArgument(1)); + var delivery = new EnglishSegmentDeliveryBizService(); + ReflectionTestUtils.setField(delivery, "questionSegmentCoreService", core); + ReflectionTestUtils.setField(delivery, "englishDictConfig", config); + ReflectionTestUtils.setField(delivery, "segmentMatchIdResolver", resolver); + assertTrue(delivery.resolveByQuestionKeys(Set.of("q1"), Map.of("q1", selectedV1)).isEmpty()); + assertTrue(delivery.resolveByQuestionKeys(Set.of("q1"), Map.of("q1", selectedV2)).containsKey("q1")); + assertTrue(delivery.resolveByQuestionKeys(Set.of("q1")).isEmpty()); + } finally { MultiDataSourceContextHolder.clear(); } + } +} --- /dev/null +++ b/database/597084-output-source-identity.sql @@ -0,0 +1,4 @@ +-- BOTH JY and PROD, before deploying generators/readers. +ALTER TABLE cc_output_data ADD COLUMN segment_source_payload_hash VARCHAR(40) NULL + COMMENT 'form-v1 canonical generation input hash; NULL legacy output must regenerate'; +-- Segment binding is stored atomically inside existing segments JSON (no segment table schema change). --- /dev/null +++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/domain/schema/common/data/OutputDataDo.java @@ -0,0 +1,71 @@ +package cn.jojo.cc.core.domain.schema.common.data; + +import cn.jojo.cc.common.annotation.TableRelation; +import cn.jojo.cc.core.domain.DomainBaseDo; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; + + + +/** + * cc_output_data + * + * 内容输出表 + * + * @author zhangyong1 + * @since 2024/03/11 + */ +@Data +@TableName("cc_output_data") +@Schema(title = "cc_output_data 内容输出表", description = "output data数据对象") +public class OutputDataDo extends DomainBaseDo { + + /** + * 数据输出SchemaId + */ + @TableRelation + @Schema(title = "form dataID", description = "form dataID") + private Long formDataId; + + /** + * 1:游戏schema 2:报告schema + */ + @Schema(title = "output data类型", description = "output data类型") + private Integer outputDataType; + + /** + * 表单schemaID + */ + @TableRelation + @Schema(title = "form schemaID", description = "form schemaID") + private Long formSchemaId; + + /** + * 数据输出SchemaId + */ + @TableRelation + @Schema(title = "output data schemaID", description = "output data schemaID") + private Long outputDataSchemaId; + + /** + * 输出数据的schema + */ + @Schema(title = "output data", description = "output data") + private String outputData; + + /** + * 数据JSON地址 + */ + @Schema(title = "url", description = "url") + private String url; + + /** + * 平台标识 + */ + @Schema(title = "ss tenantID", description = "ss tenantID") + private String ssTenantId; + + /** 标准题内容输出生成前原FormData输入身份;历史输出为null。 */ + private String segmentSourcePayloadHash; +} \ No newline at end of file --- /dev/null +++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/service/impl/schema/common/OutputDataCoreServiceImpl.java @@ -0,0 +1,122 @@ +package cn.jojo.cc.core.service.impl.schema.common; + +import cn.jojo.cc.common.enums.DeleteStatusEnum; +import cn.jojo.cc.core.dao.schema.OutputDataDao; +import cn.jojo.cc.core.domain.schema.common.data.OutputDataDo; +import cn.jojo.cc.core.service.schema.OutputDataCoreService; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; +import com.google.common.collect.Lists; +import org.apache.commons.collections4.CollectionUtils; +import org.springframework.stereotype.Service; + +import java.util.Collection; +import java.util.List; +import java.util.Objects; + +/** + * 描述: + *

+ * + * @author : lihy + * @version : Ver 1.0 + * @date : 2024/1/24 3:25 下午 + */ +@Service +public class OutputDataCoreServiceImpl extends ServiceImpl implements OutputDataCoreService { + + + @Override + public List list(Long formDataId) { + if (Objects.isNull(formDataId)) { + return Lists.newArrayList(); + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(OutputDataDo::getFormDataId, formDataId); + queryWrapper.eq(OutputDataDo::getDelFlag, DeleteStatusEnum.NOT_DELETE.getValue()); + return this.getBaseMapper().selectList(queryWrapper); + } + + @Override + public OutputDataDo get(Long formDataId, Long outputDataSchemaId) { + if (Objects.isNull(formDataId) || Objects.isNull(outputDataSchemaId)) { + return null; + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(OutputDataDo::getFormDataId, formDataId); + queryWrapper.eq(OutputDataDo::getOutputDataSchemaId, outputDataSchemaId); + queryWrapper.eq(OutputDataDo::getDelFlag, DeleteStatusEnum.NOT_DELETE.getValue()); + return this.getBaseMapper().selectOne(queryWrapper); + } + + @Override + public OutputDataDo get(Long formDataId, Integer outputDataType) { + if (Objects.isNull(formDataId) || Objects.isNull(outputDataType)) { + return null; + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.eq(OutputDataDo::getFormDataId, formDataId); + queryWrapper.eq(OutputDataDo::getOutputDataType, outputDataType); + return this.getBaseMapper().selectOne(queryWrapper); + } + + @Override + public List listByFormDataIds(Collection formDataIds, Integer delFlag) { + if (CollectionUtils.isEmpty(formDataIds)) { + return Lists.newArrayList(); + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(OutputDataDo::getFormDataId, formDataIds); + if (Objects.nonNull(delFlag)) { + queryWrapper.eq(OutputDataDo::getDelFlag, delFlag); + } + return this.getBaseMapper().selectList(queryWrapper); + } + + @Override + public List listByFormDataIdsAndType(Collection formDataIds, Integer outputType, Integer delFlag) { + if (CollectionUtils.isEmpty(formDataIds)) { + return Lists.newArrayList(); + } + LambdaQueryWrapper queryWrapper = new LambdaQueryWrapper<>(); + queryWrapper.in(OutputDataDo::getFormDataId, formDataIds); + if (Objects.nonNull(outputType)) { + queryWrapper.in(OutputDataDo::getOutputDataType, outputType); + } + if (Objects.nonNull(delFlag)) { + queryWrapper.eq(OutputDataDo::getDelFlag, delFlag); + } + return this.getBaseMapper().selectList(queryWrapper); + } + + @Override + public List listSimpleByFormDataIdsAndType(Collection formDataIds, Integer outputType, Integer delFlag) { + if (CollectionUtils.isEmpty(formDataIds)) { + return Lists.newArrayList(); + } + QueryWrapper queryWrapper = new QueryWrapper<>(); + queryWrapper.select("id,form_data_id,output_data_type,url,output_data,segment_source_payload_hash"); + queryWrapper.in("form_data_id", formDataIds); + if (Objects.nonNull(outputType)) { + queryWrapper.in("output_data_type", outputType); + } + if (Objects.nonNull(delFlag)) { + queryWrapper.eq("del_flag", delFlag); + } + return this.getBaseMapper().selectList(queryWrapper); + } + + @Override + public int updateForFlush(OutputDataDo outputDataDo, String casUrl) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.set(OutputDataDo::getOutputData, outputDataDo.getOutputData()); + updateWrapper.set(OutputDataDo::getUrl, outputDataDo.getUrl()); + updateWrapper.eq(OutputDataDo::getId, outputDataDo.getId()); + if (Objects.nonNull(casUrl)) { + updateWrapper.eq(OutputDataDo::getUrl, casUrl); + } + return this.getBaseMapper().update(null, updateWrapper); + } +}