--- a/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishSegmentOutputIdentityRecoveryJobHandler.java +++ b/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishSegmentOutputIdentityRecoveryJobHandler.java @@ -7,18 +7,22 @@ import jakarta.annotation.Resource; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Component; import java.util.concurrent.TimeUnit; -/** 迁移/修复作业,双库每轮各50题,真实输出重生成会写OSS,部署后人工启用。 */ +/** 必须持续调度的来源恢复作业,双库每轮各50题,真实输出重生成会写OSS,部署后人工启用。 */ @Component public class EnglishSegmentOutputIdentityRecoveryJobHandler { + @Resource private cn.jojo.cc.biz.common.config.EnglishDictConfig englishDictConfig; @Resource private EnglishSegmentOutputIdentityRecoveryBizService service; @Resource private RedissonClient redissonClient; @XxlJob("englishSegmentOutputIdentityRecover") public ReturnT recover(String ignored) { + if (!englishDictConfig.isSegmentEnabled() || !englishDictConfig.isSegmentExecutionEnabled()) { + return new ReturnT<>("英文分词已暂停,输出恢复未读游标/取锁/生成/上传"); + } 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<>("分词刷新/来源恢复正在执行"); } --- /dev/null +++ b/api/admin-worker/src/test/java/cn/jojo/cc/worker/task/EnglishSegmentOutputIdentityRecoveryJobHandlerTest.java @@ -0,0 +1,24 @@ +package cn.jojo.cc.worker.task; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.service.english.segment.EnglishSegmentOutputIdentityRecoveryBizService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.redisson.api.RedissonClient; +import static org.mockito.Mockito.*; + +class EnglishSegmentOutputIdentityRecoveryJobHandlerTest { + @Test void pausedJobDoesNotAcquireLockOrRegenerateOutputs() { + var job = new EnglishSegmentOutputIdentityRecoveryJobHandler(); + var config = mock(EnglishDictConfig.class); + var redis = mock(RedissonClient.class); + var service = mock(EnglishSegmentOutputIdentityRecoveryBizService.class); + when(config.isSegmentEnabled()).thenReturn(true); + when(config.isSegmentExecutionEnabled()).thenReturn(false); + ReflectionTestUtils.setField(job, "englishDictConfig", config); + ReflectionTestUtils.setField(job, "redissonClient", redis); + ReflectionTestUtils.setField(job, "service", service); + job.recover(null); + verifyNoInteractions(redis, service); + } +} --- a/application/src/main/java/cn/jojo/cc/biz/model/cache/FormAndDataCacheBo.java +++ b/application/src/main/java/cn/jojo/cc/biz/model/cache/FormAndDataCacheBo.java @@ -58,10 +58,12 @@ private JSON outDataJsonObj; /** 与outDataJson从同一OutputData行读取,旧缓存缺失即不可采信。 */ private Long segmentSourceFormDataId; private String segmentSourcePayloadHash; + /** 从同一OutputData.outputData原文计算;Redis默认序列化可丢嵌套null,故保留scalar。 */ + private String segmentSourceOutputHash; /** 保留已有六参调用兼容。 */ public FormAndDataCacheBo(Long businessId, Integer businessType, Integer businessTypeClassify, Integer outputDataType, String outDataJson, JSON outDataJsonObj) { this.businessId = businessId; this.businessType = businessType; --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentOutputIdentityRecoveryBizService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentOutputIdentityRecoveryBizService.java @@ -26,54 +26,62 @@ import org.springframework.stereotype.Service; import java.util.List; import java.util.Set; import java.util.Objects; -/** 滚动升级恢复:旧输出必须真实重生成,不能给旧URL贴新输入hash。 */ +/** 持续来源恢复:旧输出及后续跨库身份失配必须真实重生成,不能给旧URL贴新输入hash。 */ @Service @Slf4j public class EnglishSegmentOutputIdentityRecoveryBizService { + @Resource private cn.jojo.cc.biz.common.config.EnglishDictConfig englishDictConfig; @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) { + if (paused()) { return "paused: no cursor/read/regeneration/upload"; } 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) { + if (paused()) { return summary.append("paused; cursor retained").toString(); } 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); } + if (finished) { + cursor.set(0L); + log.info("输出身份恢复完整扫描一圈, env={}, lastId={}, 本轮失败={}; 持续Job下一轮重新扫描", env, after, failed); + } 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) { + if (paused()) { return; } SegmentPayloadIdentity identity = content.readSource(question).identity(); if (identity == null || !identity.inputKnown()) { throw new IllegalStateException("原FormData不可用,不能伪造输出身份"); } if (!hasMatchingOutput(identity)) { // 现有公开入口按真实schema重生成并上传输出,genOutPutData在生成前记录输入身份。 + if (paused()) { return; } questionBizService.refreshGenStandQuestion(question.getId(), operator); identity = content.readSource(question).identity(); if (identity == null || !identity.inputKnown() || !hasMatchingOutput(identity)) { throw new IllegalStateException("输出未能按当前输入重生成,保持不可点击"); } @@ -89,10 +97,14 @@ skeletons.ensurePair(cn.jojo.cc.biz.service.component.CommonService.incrementAndGet(question.getId()), question.getId()); } segments.refreshOne(question, env, operator); } + private boolean paused() { + return !englishDictConfig.isSegmentEnabled() || !englishDictConfig.isSegmentExecutionEnabled(); + } + 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/SelectedSegmentOutput.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SelectedSegmentOutput.java @@ -12,8 +12,8 @@ || !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()))); + selected.getSegmentSourceOutputHash())); } } --- a/application/src/main/java/cn/jojo/cc/biz/service/impl/schema/data/FormDataBizServiceImpl.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/impl/schema/data/FormDataBizServiceImpl.java @@ -300,10 +300,12 @@ String outPutData = Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getOutputData) .orElse(StringUtils.EMPTY); if (StringUtils.isNotBlank(outPutData)) { try { bo.setOutDataJsonObj(JSON.parseObject(outPutData)); + bo.setSegmentSourceOutputHash(cn.jojo.cc.biz.service.english.segment.SegmentPayloadIdentity + .outputHash(bo.getOutDataJsonObj())); } catch (Exception exception) { log.error("[FormDataBizServiceImpl.listFormAndDataCacheBo] outPutData not a standard json,id:{}", e.getId()); bo.setOutDataJsonObj(null); } @@ -406,10 +408,12 @@ String outPutData = Optional.ofNullable(formDataIdMap.get(e.getId())).map(OutputDataDo::getOutputData) .orElse(StringUtils.EMPTY); if (StringUtils.isNotBlank(outPutData)) { try { bo.setOutDataJsonObj(JSON.parseObject(outPutData)); + bo.setSegmentSourceOutputHash(cn.jojo.cc.biz.service.english.segment.SegmentPayloadIdentity + .outputHash(bo.getOutDataJsonObj())); } catch (Exception exception) { log.error("[FormDataBizServiceImpl.listFormAndDataCacheBo] outPutData not a standard json,id:{}", e.getId()); bo.setOutDataJsonObj(null); } --- a/application/src/test/java/cn/jojo/cc/biz/service/english/segment/SegmentResponseIdentityRegressionTest.java +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/SegmentResponseIdentityRegressionTest.java @@ -24,22 +24,39 @@ SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":1}"))); assertNotEquals(SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":[1,2]}")), SegmentPayloadIdentity.hash(JSON.parseObject("{\"a\":[2,1]}"))); } + @Test void actualDefaultCacheSerializerRoundTripPreservesScalarDespiteNestedNullLoss() { + var output = JSON.parseObject("{\"body\":\"hello\",\"explicitNull\":null,\"array\":[1,null,2]}"); + var bo = new FormAndDataCacheBo(8L, 1, 2, 1, "/v2.json", output); + bo.setSegmentSourceFormDataId(7L); + bo.setSegmentSourcePayloadHash(SegmentPayloadIdentity.hash(output)); + String expected = SegmentPayloadIdentity.outputHash(output); + bo.setSegmentSourceOutputHash(expected); + var cached = JSON.parseObject(JSON.toJSONString(bo), FormAndDataCacheBo.class); + var selected = SelectedSegmentOutput.capture(DataBaseTypeEnum.TEACHING, 8L, cached, "/v2.json"); + assertTrue(selected.known()); + assertEquals(expected, selected.identity().outputPayloadHash()); + cached.setSegmentSourceOutputHash(null); + assertFalse(SelectedSegmentOutput.capture(DataBaseTypeEnum.TEACHING, 8L, cached, "/v2.json").known()); + } + @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); + bo.setSegmentSourceOutputHash(SegmentPayloadIdentity.outputHash(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); + bo.setSegmentSourceOutputHash(SegmentPayloadIdentity.outputHash(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());