--- a/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishBatchPublishTask.java +++ b/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishBatchPublishTask.java @@ -109,10 +109,14 @@ /** * 失败条目逐条落日志:没有进度接口之后,日志是定位"哪条为什么没发出去"的唯一出口。 */ private void logFailures(String entity, BatchPublishResultBo batch) { + if (batch.getPendingCacheCount() > 0) { + log.info("[englishBatchPublish] {} PROD已提交,缓存失效待outbox恢复确认: {} 条", + entity, batch.getPendingCacheCount()); + } if (batch.getFailures() == null || batch.getFailures().isEmpty()) { return; } for (EnglishTaskFailureBo failure : batch.getFailures()) { if (failure == null) { --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/model/bo/english/BatchPublishResultBo.java @@ -0,0 +1,43 @@ +package cn.jojo.cc.biz.model.bo.english; + +import lombok.Data; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; + +/** + * 描述:批量发布任务结果。 + * + *

批量发布按主表 ID 游标分批,单条失败不影响其它条目; + * {@code nextCursor} 为最后处理到的主表 ID,{@code finished=false} 表示还需继续投递。

+ */ +@Data +public class BatchPublishResultBo implements Serializable { + + private static final long serialVersionUID = 1L; + + /** 本批扫描条数 */ + private int scannedCount; + + /** 本批发布成功条数 */ + private int successCount; + + /** 本批发布失败条数 */ + private int failedCount; + + /** 本批最后处理到的主表 ID,作为下一批游标 */ + private Long nextCursor; + + /** 是否已处理完整表数据 */ + private boolean finished; + + /** + * 本批失败条目明细:identifier=类型+实体 ID、reason=失败原因; + * 由 XxlJob 入口 {@code englishBatchPublish} 逐条落 warn 日志(不再存进度), + * 日志是定位“哪条为何失败”的唯一出口。 + */ + private List failures = new ArrayList<>(); + /** 已提交PROD但缓存失效待outbox恢复者确认的条目数,包含在successCount中。 */ + private int pendingCacheCount; +} --- a/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictPublishProServiceHelper.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictPublishProServiceHelper.java @@ -100,10 +100,11 @@ // 在途任务号只属于教研库:学生库不跑生成任务,带上它只会让同行永远显示「生成中」 prodWord.setAudioTaskId(""); prodWord.setAudioTaskTime(0L); prodWord.setStatus(EnglishPublishStatusEnum.PUBLISHED.getCode()); prodWord.setHasPublished(1); + prodWord.setAutoPublishEligible(0); prodWord.setDeleteTime(0L); prodWord.fillRequired(operator, now, false); englishWordCoreService.saveOrUpdate(prodWord); List writes = new ArrayList<>(); --- a/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictWriteTxService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictWriteTxService.java @@ -86,10 +86,11 @@ if (!isNew) { // 更新走非空字段策略,只需带 ID 与要改的列;has_published 必须不赋值:它只能由发布链路 0→1 幂等登记, // 把锁外快照回写回去会把并发发布刚登记的“曾发布”冲掉(问题:改名与发布并发后删除闸门放行)。 target.setId(existing.getId()); } + target.setAutoPublishEligible(0); target.setWord(StringUtils.trim(bo.getWord())); target.setWordNormalized(normalized); target.setPhonetic(bo.getPhonetic()); target.setAudioResourceId(bo.getAudioResourceId()); // 编辑时未提交的字段走非空更新策略、库里保持原值,来源也必须跟着保留:回落成「人工」 @@ -173,10 +174,11 @@ target.setWord(StringUtils.trim(wordText)); target.setWordNormalized(normalized); target.setPhonetic(phonetic); target.setAudioResourceId(audioResourceId); target.setSource(DictSourceEnum.SEGMENT_AUTOFILL.getCode()); + target.setAutoPublishEligible(1); target.setStatus(EnglishPublishStatusEnum.UNPUBLISHED.getCode()); target.setHasPublished(0); // 空壳/缺项由完整性计算自然降级为 INCOMPLETE,不另外判定(Spec §7.5)。 target.setCompleteness(calcWordCompleteness(target, translations)); target.fillRequired(operator, now, true); --- a/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java @@ -135,11 +135,11 @@ EnglishWordDo current = requireWord(wordId); boolean eligible = cn.jojo.cc.common.enums.english.DictSourceEnum.SEGMENT_AUTOFILL.getCode() .equals(current.getSource()) && EnglishPublishStatusEnum.UNPUBLISHED.getCode().equals(current.getStatus()) && !Integer.valueOf(1).equals(current.getHasPublished()) - && Objects.equals(current.getCreateTime(), current.getUpdateTime()); + && Integer.valueOf(1).equals(current.getAutoPublishEligible()); return eligible ? doPublishWord(current, operator) : new PublishResultBo(); }); } /** @@ -188,12 +188,15 @@ result.setScannedCount(result.getScannedCount() + 1); result.setNextCursor(word.getId()); try { PublishResultBo single = englishDictWriteLockService.callWithWordLock(word.getId(), word.getWordNormalized(), () -> doPublishWord(requireWord(word.getId()), operator)); - if (single.isProdPublished() && !single.isCacheInvalidationPending()) { + if (single.isProdPublished()) { result.setSuccessCount(result.getSuccessCount() + 1); + if (single.isCacheInvalidationPending()) { + result.setPendingCacheCount(result.getPendingCacheCount() + 1); + } } else { result.setFailedCount(result.getFailedCount() + 1); // 未发布成功也要带原因上浮(问题08),否则任务侧只有计数没有条目。 addBatchFailure(result.getFailures(), "WORD", word.getId(), StringUtils.defaultIfBlank(single.getFailReason(), "PROD 写入未确认")); @@ -222,12 +225,15 @@ for (EnglishPhraseDo phrase : pending) { result.setScannedCount(result.getScannedCount() + 1); result.setNextCursor(phrase.getId()); try { PublishResultBo single = publishPhrase(phrase.getId(), operator); - if (single.isProdPublished() && !single.isCacheInvalidationPending()) { + if (single.isProdPublished()) { result.setSuccessCount(result.getSuccessCount() + 1); + if (single.isCacheInvalidationPending()) { + result.setPendingCacheCount(result.getPendingCacheCount() + 1); + } } else { result.setFailedCount(result.getFailedCount() + 1); addBatchFailure(result.getFailures(), "PHRASE", phrase.getId(), StringUtils.defaultIfBlank(single.getFailReason(), "PROD 写入未确认")); } --- a/application/src/main/java/cn/jojo/cc/biz/service/english/dict/SegmentAutoFillBizServiceImpl.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/dict/SegmentAutoFillBizServiceImpl.java @@ -167,20 +167,16 @@ // 还必须验出身(第四轮复审问题12):“待发布+从未发布”这个状态人工/导入草稿也有, // 只判状态就等于替运营把他还没审完的词条发到学生端——发布是人工闸门,系统越不过去。 // 本仓词典无“撤回发布”,且 hasPublished 仅发布链路写,所以 // “补录出身 + 待发布 + 从未发布 + 建后未被编辑”只会是补录自己建到一半的行, // 自动补发仍是设计内恢复。 - // 最后一条不可省:运营手工补全过的行也是“补录出身+待发布+从未发布”, - // 不排除就会替运营把它发出去、绕过人工闸门。编辑会刷新 update_time, - // 而发布回写(updatePublishStatusIfUnmodified / registerHasPublished) - // 只改状态不动时间,所以两列相等 = 建行后没人改过,且不影响发布失败后的重试。 - // 已知残留窗口:本判定与下面 publishWord 之间若运营恰好完成一次保存,被发布的就是他刚保存的 - // 版本,且 hasPublished 置 1 后该词不可再删;窗口只有几行代码,换来的是双方都不再互等超时。 + // 仅由新建补录置1,任何人工保存立即清零;不再以秒级审计时间相等证明未编辑。 + // 历史行迁移默认0,需人工发布,禁止依据旧时间戳回填资格。 boolean needPublish = DictSourceEnum.SEGMENT_AUTOFILL.getCode().equals(saved.getSource()) && EnglishPublishStatusEnum.UNPUBLISHED.getCode().equals(saved.getStatus()) && !Integer.valueOf(1).equals(saved.getHasPublished()) - && Objects.equals(saved.getCreateTime(), saved.getUpdateTime()); + && Integer.valueOf(1).equals(saved.getAutoPublishEligible()); if (needPublish) { rocketMqProducerService.clearRedisCommonCache(EnglishWordDo.class, saved.getId(), EnglishDictCacheBoFactory.wordInvalidateBos(DataBaseTypeEnum.TEACHING, saved.getId(), null, saved.getWordNormalized())); englishPublishBizService.publishAutoFillWord(saved.getId(), operator); --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/DefaultSegmentTextExtractor.java @@ -0,0 +1,300 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.common.enums.dict.QuestionTypeEnum; +import cn.jojo.cc.common.enums.english.SegmentContentTypeEnum; +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.core.domain.question.library.QuestionContentDo; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +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.service.question.library.QuestionContentCoreService; +import cn.jojo.cc.core.service.schema.FormDataCoreService; +import cn.jojo.cc.core.service.schema.OutputDataCoreService; +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +/** + * 标准题富文本抽取。 + * + *

字段路径均已由 Schema(`$.questionInfo.answerArea.type=object`、 + * `$.questionInfo.answerArea.textAnswerAreaContent`)与下发 DTO 坐实; + * 当前按产品范围只处理选择、填空、输入、完形填空的材料/作答区文字。

+ */ +@Component +@Slf4j +public class DefaultSegmentTextExtractor implements SegmentTextExtractor { + + private static final String QUESTION_INFO = "questionInfo"; + private static final String MATERIAL = "material"; + private static final String MATERIAL_CONTENT = "materialContent"; + private static final String TEXT = "text"; + private static final String UNITY = "unity"; + private static final String UNITY_TEXT = "unityText"; + private static final String ANSWER_AREA = "answerArea"; + private static final String TEXT_ANSWER_AREA_CONTENT = "textAnswerAreaContent"; + private static final String CLASSIFY_NAME = "classifyName"; + private static final String QUESTION_INFO_SNAKE = "question_info"; + private static final String TEXT_FILL_AREA = "text_fill_area"; + private static final String AREA_TEXT = "area_text"; + + @Resource + private EnglishDictConfig englishDictConfig; + + @Resource + private FormDataCoreService formDataCoreService; + + @Resource + private OutputDataCoreService outputDataCoreService; + + @Resource + private QuestionContentCoreService questionContentCoreService; + + private Collection questionTypeWhitelist = List.of(); + private Function formDataResolver; + private Function historicalContentResolver; + + /** + * 测试入口:未装配 {@link EnglishDictConfig} 时直接给定题型白名单;线上取值一律走配置类。 + */ + void setQuestionTypeWhitelist(Collection questionTypeWhitelist) { + this.questionTypeWhitelist = questionTypeWhitelist; + } + + /** + * 测试入口:直接提供表单 JSON。 + */ + void setFormDataResolver(Function formDataResolver) { + this.formDataResolver = formDataResolver; + } + + /** + * 测试入口:直接提供历史题内容 JSON。 + */ + void setHistoricalContentResolver(Function historicalContentResolver) { + 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; + } + } + + @Override + public String extractPlainText(String richText) { + if (StringUtils.isBlank(richText)) { + return ""; + } + Document document = Jsoup.parse(richText); + document.select("formula, img, script, style").forEach(element -> { + element.before(" "); + element.remove(); + }); + String text = document.text(); + // 保留音标token原貌,交给EnglishWords统一排除;先剔音标字母会漏出ASCII残片。 + return text.replaceAll("\\s+", " ").trim(); + } + + private Collection currentWhitelist() { + if (englishDictConfig != null) { + // 配置存在就照单全收,包括空集合:空 = 显式关闭分词,不得在此回退默认名单, + // 否则与候选题扫描的解读相反,同一份配置会出现两种行为。 + 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); + } + if (questionContentCoreService == null) { + return null; + } + QuestionContentDo content = questionContentCoreService.getByQuestionId(question.getId()); + return content == null ? null : content.getContent(); + } + + private Map> extractFromFormData(Integer questionType, String json) { + JSONObject root = JSON.parseObject(json); + Map> result = new LinkedHashMap<>(); + if (root == null) { + return result; + } + Integer contentType = SegmentContentTypeEnum.contentTypeOfQuestionType(questionType); + if (contentType == null) { + return result; + } + List texts; + if (SegmentContentTypeEnum.MATERIAL.getCode().equals(contentType)) { + texts = extractMaterialTexts(root); + } else if (questionType.equals(QuestionTypeEnum.GENERAL_FILL.getCode()) + || questionType.equals(QuestionTypeEnum.STAND_CLOZE_QUESTION.getCode()) + || questionType.equals(QuestionTypeEnum.GENERAL_INPUT.getCode())) { + texts = extractAnswerAreaTexts(root); + } else { + return result; + } + // 抽不到文字就不产内容位:产一条空行会让该题因为“空序列”被当作已分词,后续不再重试。 + if (!texts.isEmpty()) { + result.put(contentType, texts); + } + return result; + } + + private List extractMaterialTexts(JSONObject root) { + JSONObject questionInfo = root.getJSONObject(QUESTION_INFO); + if (questionInfo == null) { + return new ArrayList<>(0); + } + JSONObject material = questionInfo.getJSONObject(MATERIAL); + if (material == null) { + return new ArrayList<>(0); + } + JSONArray contents = material.getJSONArray(MATERIAL_CONTENT); + List result = new ArrayList<>(); + if (contents == null) { + return result; + } + for (int i = 0; i < contents.size(); i++) { + JSONObject content = contents.getJSONObject(i); + addText(result, content == null ? null : content.getJSONObject(TEXT)); + } + return result; + } + + /** + * 作答区正文:填空、完形、输入三类题型同源。 + * + *

{@code questionInfo.answerArea} 在 Schema 里是 **object**(`$.questionInfo.answerArea` type=object), + * 不是数组;早期实现用 {@code getJSONArray} 读会抛 JSONObject→JSONArray 类型转换异常。 + * 正文取 {@code answerArea.textAnswerAreaContent}(富文本对象,与 materialContent[].text 同构)。 + * 输入题早期改从 {@code determineInfo.inputBoxContent} 取,那是判定用的答案,不是学生看到的正文, + * 且新 Schema 已改为 {@code determineInfo.answerContent},字段名本身也对不上。

+ */ + private List extractAnswerAreaTexts(JSONObject root) { + JSONObject questionInfo = root.getJSONObject(QUESTION_INFO); + List result = new ArrayList<>(); + if (questionInfo == null) { + return result; + } + Object answerAreaNode = questionInfo.get(ANSWER_AREA); + if (answerAreaNode instanceof JSONObject) { + // 填空/完形/输入题(b-answer-area-fill / input-standard):answerArea 是对象, + // 正文在 textAnswerAreaContent(图片作答区没有富文本,取不到就不产内容位)。 + addText(result, ((JSONObject) answerAreaNode).getJSONObject(TEXT_ANSWER_AREA_CONTENT)); + } else if (answerAreaNode instanceof JSONArray) { + // 空格分类类组件(b-answer-area-ws-classify)把作答区做成数组,正文在每项 classifyName。 + // 与上面的富文本二选一(按实际类型只走一支),不会重复抽取。 + for (Object item : (JSONArray) answerAreaNode) { + if (item instanceof JSONObject) { + addText(result, ((JSONObject) item).getJSONObject(CLASSIFY_NAME)); + } + } + } + return result; + } + + private Map> extractFromHistoricalContent(String json) { + JSONObject root = JSON.parseObject(json); + Map> result = new LinkedHashMap<>(); + if (root == null) { + return result; + } + // 历史内容只有两个已知字段:题干文本归“文字材料”,填空区归“作答区文字”。 + JSONObject questionInfo = root.getJSONObject(QUESTION_INFO_SNAKE); + if (questionInfo != null) { + String text = questionInfo.getString(TEXT); + if (StringUtils.isNotBlank(text)) { + result.computeIfAbsent(SegmentContentTypeEnum.MATERIAL.getCode(), + key -> new ArrayList<>()).add(text); + } + } + JSONObject fillArea = root.getJSONObject(TEXT_FILL_AREA); + if (fillArea != null) { + String areaText = fillArea.getString(AREA_TEXT); + if (StringUtils.isNotBlank(areaText)) { + result.computeIfAbsent(SegmentContentTypeEnum.ANSWER_AREA.getCode(), + key -> new ArrayList<>()).add(areaText); + } + } + return result; + } + + private void addText(List result, JSONObject textObject) { + if (textObject == null) { + return; + } + String unity = textObject.getString(UNITY); + if (StringUtils.isBlank(unity)) { + unity = textObject.getString(UNITY_TEXT); + } + if (StringUtils.isNotBlank(unity)) { + result.add(unity); + } + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizService.java @@ -0,0 +1,202 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.model.bo.english.SegmentSnapshot; +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.english.SegmentStatusEnum; +import cn.jojo.cc.core.domain.english.QuestionSegmentDo; +import cn.jojo.cc.core.multidatasource.MultiDataSourceContextHolder; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import com.alibaba.fastjson.JSON; +import jakarta.annotation.Resource; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * 描述:题目下发侧的划词装配(Spec §9.3、工作包 10 Task 23)。 + * + *

职责边界很窄:只读 {@code cc_question_segment} 并把库存快照还原成下发结构,不做任何分词 + * ——扫描与块划分在保存时(08 挂接)与全量刷新时(09)完成。也不查词典细节:详情由消费端拿 ID + * 另走接口 B。

+ * + *

返回的是 {@code questionKey -> segments} 映射,没有分词行的 key 不出现在结果里: + * 调用方据此保持 {@code segments=null}。这是刻意的区分——“没分过词”与“分过词但一个没命中” + * 在消费端是两种表现,不能用空序列冒充后者。

+ * + *

只透出 {@code segment_status=NORMAL} 的行(第四轮复审问题04):失败行的 segments 里装的仍是 + * 上一次成功时的旧正文序列(失败登记只改状态/正文指纹,不重写序列),而划词是按文字顺序定位的, + * 正文一变旧序列就会漏标或标错位置。下发层拿不到题目行,所以以“行自身状态”为唯一可信判据—— + * 写侧已保证正文推进后不再对应的成功行会被降级为失败,二者合起来才成立。

+ */ +@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; + + @Resource + private SegmentMatchIdResolver segmentMatchIdResolver; + + /** + * 按当前线程数据源批量解析划词序列。 + * + *

库的选择跟着线程走而不是跟着入参 {@code env} 走:{@code listStandardQuestion} 的 split + * 允许按 {@code courseId} 路由(此时 {@code env} 为 null),只有线程数据源与“实际读到的分词行” + * 恒等对应,不会因为两套标识漂移而把教研库的 ID 解析进正式库的响应。

+ * + *

单题解析失败只丢该题(返回结果里不含它),绝不把异常抛给整批下发。库的判定不需要兼容“无库”态: + * {@link MultiDataSourceContextHolder#getDataSource()} 在未显式设置时会按 druid 配置兜底成 DEFAULT 或 PROD, + * 线程上永远是一个具体库,与本次实际读到的分词行一致。

+ * + * @param questionKeys 题目的 questionKey 集合,须与库内 {@code question_key} 同形态 + * @return questionKey -> 已按当前词典解析过 ID 的划词序列;无分词行或解析失败的 key 不在结果中 + */ + @org.springframework.transaction.annotation.Transactional(readOnly = true) + public Map resolveByQuestionKeys(Set questionKeys) { + // 回滚契约(总开关一键止血):关闭后不查库、整批不下发 segments, + // 题目照常返回、C 端划词一律降级为不可点;仅关新增落库不叫止血。 + if (!englishDictConfig.isSegmentEnabled()) { + return Collections.emptyMap(); + } + if (CollectionUtils.isEmpty(questionKeys)) { + return Collections.emptyMap(); + } + DataBaseTypeEnum source = currentSource(); + Set distinct = new LinkedHashSet<>(); + for (String key : questionKeys) { + if (StringUtils.isNotBlank(key)) { + distinct.add(key); + } + } + if (distinct.isEmpty()) { + return Collections.emptyMap(); + } + List rows = questionSegmentCoreService.listByQuestionKeys(distinct); + if (CollectionUtils.isEmpty(rows)) { + return Collections.emptyMap(); + } + Map> rowsByKey = new HashMap<>(rows.size()); + for (QuestionSegmentDo row : rows) { + 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; } + merged = resolveGroup(source, entry.getValue()); + } catch (Exception e) { + log.warn("核验当前正文指纹失败,本题不下发划词, questionKey={}", entry.getKey(), e); + continue; + } + if (merged != null) { + result.put(entry.getKey(), merged); + } + } + return result; + } + + /** + * 同一道题的一组内容位 -> 一条下发结构。 + * + *

按内容位类型升序拼接:C 端按下发顺序逐条定位,顺序就是位置,打乱就会标错位置。 + * 任一行不是成功态或解析失败则整题不下发——缺一段会让该段之后的所有词整体错位, + * 宁可不透出(全部不可点),也不交错位的序列。

+ */ + private EnglishSegmentDto resolveGroup(DataBaseTypeEnum source, List groupRows) { + List sorted = new ArrayList<>(groupRows); + sorted.sort(Comparator.comparing( + row -> row.getContentType() == null ? Integer.MAX_VALUE : row.getContentType())); + List matches = new ArrayList<>(); + for (QuestionSegmentDo row : sorted) { + EnglishSegmentDto one = resolveOne(source, row); + if (one == null) { + return null; + } + if (one.getMatches() != null) { + matches.addAll(one.getMatches()); + } + } + EnglishSegmentDto merged = new EnglishSegmentDto(); + merged.setMatches(matches); + return merged; + } + + /** + * 一行快照 -> 下发结构。库存只存 text/type,历史遗留的 ID 字段在此被忽略并由解析结果覆盖。 + */ + private EnglishSegmentDto resolveOne(DataBaseTypeEnum source, QuestionSegmentDo row) { + if (row == null || StringUtils.isBlank(row.getQuestionKey()) || StringUtils.isBlank(row.getSegments())) { + return null; + } + if (!SegmentStatusEnum.NORMAL.getCode().equals(row.getSegmentStatus())) { + // 失败/状态缺失的行不透出:这道题本次退回“词不可点”,比按旧正文标错位置安全。 + log.info("分词行非成功状态,本题不下发 segments, questionKey={}, status={}", + row.getQuestionKey(), row.getSegmentStatus()); + return null; + } + try { + SegmentSnapshot snapshot = JSON.parseObject(row.getSegments(), SegmentSnapshot.class); + if (snapshot == null) { + log.error("划词快照为空对象,本题不下发 segments, questionKey={}", row.getQuestionKey()); + return null; + } + return segmentMatchIdResolver.resolveIds(source, snapshot.toDto()); + } catch (Exception e) { + // 脏 JSON 只丢这一题:不能因为一行坏数据让整个 listStandardQuestion 失败。 + log.error("划词快照解析失败,本题不下发 segments, questionKey={}", row.getQuestionKey(), e); + return null; + } + } + + /** + * 当前线程数据源 -> 词典数据源。 + * + *

不保留“判不出库”分支:框架已保证线程上必为 DEFAULT 或 PROD(未设置时按 + * {@code bs.base-config.druid} 兜底),写一个永不可达的防御只会误导读者。

+ */ + private DataBaseTypeEnum currentSource() { + return MultiDataSourceContextHolder.isJYDatasource() ? DataBaseTypeEnum.TEACHING : DataBaseTypeEnum.PROD; + } +} --- 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 @@ -20,11 +20,10 @@ import com.alibaba.fastjson.JSON; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; import java.util.Collections; import java.util.LinkedHashSet; import java.util.List; @@ -54,11 +53,14 @@ * 登记只会写入失败(strict)或被截断污染唯一键(non-strict)。 */ private static final int MAX_UNMATCHED_WORD_LENGTH = 99; @Resource - private SegmentTextExtractor segmentTextExtractor; + private SegmentContentSnapshotService segmentContentSnapshotService; + + @Resource + private QuestionSegmentTxService questionSegmentTxService; /** * 执行闸门:本类是全部“真算一遍”的入口(保存后异步计算、全量刷、发布后重算), * 暂停时不得抽正文、查词典、写行。 */ @@ -96,35 +98,11 @@ * *

同类型内的多个文本块最终合并成一条匹配序列,不同内容位各自成行;抽取不到文字的类型 * 不会出现在结果里(不产空行,否则该题会被当成“已分词”而不再重试)。

*/ private Map> extractPlainTextsByContentType(QuestionDo question) { - Map> grouped = new TreeMap<>(); - if (question == null || question.getId() == null) { - return grouped; - } - Map> richTexts = - segmentTextExtractor.extractRichTextsByContentType(question); - if (richTexts == null || richTexts.isEmpty()) { - return grouped; - } - for (Map.Entry> entry : richTexts.entrySet()) { - List plainTexts = new ArrayList<>(); - for (String richText : entry.getValue()) { - if (StringUtils.isBlank(richText)) { - continue; - } - String plainText = segmentTextExtractor.extractPlainText(richText); - if (StringUtils.isNotBlank(plainText)) { - plainTexts.add(plainText); - } - } - if (!plainTexts.isEmpty()) { - grouped.put(entry.getKey(), plainTexts); - } - } - return grouped; + return segmentContentSnapshotService.readTexts(question); } private SegmentResult match(QuestionDo question, DataBaseTypeEnum segmentSource, PhraseIndexSnapshot snapshot, List plainTexts) { if (question == null || question.getId() == null || segmentSource == null @@ -199,44 +177,68 @@ result.setQuestionRevision(question.getUpdateTime()); return result; } @Override - @Transactional(rollbackFor = Exception.class) public SegmentResult segmentAndSave(QuestionDo question, DataBaseTypeEnum segmentSource, LoginUserBo operator) { - return doSegmentAndSave(question, segmentSource, operator, false); + return runSegmentAttempt(question, segmentSource, operator, false); } @Override - @Transactional(rollbackFor = Exception.class) public SegmentResult refreshOne(QuestionDo question, DataBaseTypeEnum segmentSource, LoginUserBo operator) { - return doSegmentAndSave(question, segmentSource, operator, true); + return runSegmentAttempt(question, segmentSource, operator, true); } @Override - @Transactional(rollbackFor = Exception.class) public SegmentResult refreshOneAllowSkip(QuestionDo question, DataBaseTypeEnum segmentSource, LoginUserBo operator) { - return doSegmentAndSave(question, segmentSource, operator, false); + return runSegmentAttempt(question, segmentSource, operator, false); + } + + private SegmentResult runSegmentAttempt(QuestionDo question, DataBaseTypeEnum source, + LoginUserBo operator, boolean force) { + if (question == null || question.getId() == null || !englishDictConfig.isSegmentExecutionEnabled()) { + return new SegmentResult(); + } + try { + SegmentResult result = questionSegmentTxService.compute(question, + latest -> doSegmentAndSave(latest, source, operator, force)); + return result == null ? new SegmentResult() : result; + } catch (SegmentAttemptFailure failure) { + // 此时compute代理已回滚并释放题锁,REQUIRES_NEW不会自锁。 + if (failure.register) { + try { + questionSegmentTxService.recordFailure(failure.question, failure.md5, + failure.types, failureMessage((Exception) failure.getCause())); + } catch (Exception recordError) { + log.error("分词失败独立登记失败, questionId={}", question.getId(), recordError); + } + } + throw (RuntimeException) failure.getCause(); + } + } + + private static final class SegmentAttemptFailure extends RuntimeException { + private final QuestionDo question; + private final String md5; + private final Set types; + private final boolean register; + private SegmentAttemptFailure(QuestionDo question, String md5, Set types, + boolean register, RuntimeException cause) { + super(cause); + this.question = question; + this.md5 = md5; + this.types = new LinkedHashSet<>(types); + this.register = register; + } } private SegmentResult doSegmentAndSave(QuestionDo question, DataBaseTypeEnum segmentSource, LoginUserBo operator, boolean force) { if (question == null || question.getId() == null) { - return new SegmentResult(); - } - // 锁真实题目行,随后只处理锁内最新版本。跨内容位写入/删除和空结果清理共享这一事务。 - QuestionDo latest = questionSegmentCoreService.lockQuestion(question.getId()); - if (latest == null || !Objects.equals(latest.getUpdateTime(), question.getUpdateTime())) { - return new SegmentResult(); - } - question = latest; - if (!englishDictConfig.isSegmentExecutionEnabled()) { - // 急停:不抽正文、不装索引、不查词典、不写行,也不登记失败行(暂停不是失败)。 - log.warn("英文分词执行已暂停,跳过单题计算, questionId={}", question.getId()); return new SegmentResult(); } // 抽取本身失败时按 Spec 约定登记空串 MD5,抽取成功后替换为真实正文 MD5。 String sourceTextMd5 = EMPTY_TEXT_MD5; // 剪枝命中路径只重算返回值、库里已有同修订同正文的成功结果,不能因本次重算异常把好行降级。 @@ -244,11 +246,11 @@ // 本次实际抽取出的内容位必须在 try 外可见:catch 登记失败行时要按“这一版正文属于哪些内容位”, // 不能拿库里的旧行反推。 Map> textsByType = new TreeMap<>(); try { textsByType = extractPlainTextsByContentType(question); - sourceTextMd5 = MD5Util.creatMd5(flatten(textsByType)); + sourceTextMd5 = segmentContentSnapshotService.fingerprint(textsByType); // 一次读出该题全部内容位行:剪枝、修订判定与拒绝分支都要按内容位各看自己那一行。 Map existingByType = toMapByType(questionSegmentCoreService.listByQuestionId(question.getId())); for (QuestionSegmentDo existing : existingByType.values()) { if (isStale(existing, question.getUpdateTime())) { return new SegmentResult(); } @@ -313,14 +315,12 @@ return merged; } catch (Exception e) { // 抽取/读库/匹配/落库任一阶段失败都要登记 FAILED;否则首次失败无占位行,重试扫描永远发现不了。 log.error("题目分词处理失败, questionId={}, source={}, sourceTextMd5={}", question.getId(), segmentSource, sourceTextMd5, e); - if (!reuseStoredResult) { - registerFailure(question, sourceTextMd5, textsByType.keySet(), e); - } - throw e; + RuntimeException cause = e instanceof RuntimeException ? (RuntimeException) e : new IllegalStateException(e); + throw new SegmentAttemptFailure(question, sourceTextMd5, textsByType.keySet(), !reuseStoredResult, cause); } } /** * 逐内容位分别匹配,返回 类型 -> 该类型的匹配结果(按内容位类型升序)。 @@ -364,15 +364,11 @@ } /** * 正文指纹按“内容位顺序拼接”计算:与拆分前同口径,避免同题同文因内容位划分变化而反复重算。 */ - private String flatten(Map> textsByType) { - List all = new ArrayList<>(); - textsByType.values().forEach(all::addAll); - return String.join("\n", all); - } + private Map toMapByType(List rows) { Map map = new TreeMap<>(); if (rows == null) { return map; @@ -487,35 +483,11 @@ } /** * 登记失败行;登记自身异常只记日志,不吞掉也不覆盖原始异常。 */ - private void registerFailure(QuestionDo question, String sourceTextMd5, - Set contentTypes, Exception cause) { - try { - // 本次已经抽出了内容位:逐行登记失败,成功行与失败行的内容位才能一一对应。 - // 不能再用“库里第一行”反推,旧行可能是上一版题型留下的 MATERIAL,而本次实际是 ANSWER_AREA。 - if (!contentTypes.isEmpty()) { - for (Integer contentType : contentTypes) { - questionSegmentCoreService.markFailedByQuestionId(question.getId(), contentType, - question.getQuestionKey(), sourceTextMd5, question.getUpdateTime(), - failureMessage(cause)); - } - return; - } - // 抽取阶段就失败,拿不到内容位:按题型兜底。兜底映射与抽取器共用同一份规则; - // 题型也不认识时沿用历史默认 MATERIAL,至少留下一条可重试的失败行。 - Integer fallbackType = SegmentContentTypeEnum.contentTypeOfQuestionType(question.getQuestionType()); - questionSegmentCoreService.markFailedByQuestionId(question.getId(), - fallbackType == null ? SegmentContentTypeEnum.MATERIAL.getCode() : fallbackType, - question.getQuestionKey(), sourceTextMd5, question.getUpdateTime(), - failureMessage(cause)); - } catch (Exception e) { - log.error("题目分词失败登记失败, questionId={}, sourceTextMd5={}", - question.getId(), sourceTextMd5, e); - } - } + /** * 异常可能无 message(如 NPE),统一用简单类型名保证 last_error 可诊断且不出现两种前缀口径。 */ private String failureMessage(Exception cause) { --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentSaveHookService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentSaveHookService.java @@ -63,12 +63,11 @@ return; } if (question == null || question.getId() == null) { return; } - questionSegmentStateTxService.invalidateAfterSave(question.getId()); - // 同库事务已把旧结果降为 INIT,随后才发提交后事件;监听器内部兜住全部异常, + // 事务内只登记事件;派生表失败不能污染主事务rollback-only。监听器内部兜住全部异常, // 不会再有带 @Transactional 的 bean 抛异常污染本次同步事务。 applicationEventPublisher.publishEvent(new QuestionSegmentSaveRequestedEvent(this, question, operator)); } /** @@ -83,10 +82,16 @@ if (event == null || event.getQuestion() == null || event.getQuestion().getId() == null) { return; } QuestionDo question = event.getQuestion(); try { + questionSegmentStateTxService.invalidateAfterSave(question.getId()); + } catch (Exception e) { + // 主事务已经提交;下发正文指纹门仍阻止旧序列,继续投递计算消息修复。 + log.warn("提交后分词失信失败,保留题目主体并继续投递计算, questionId={}", question.getId(), e); + } + try { EnglishSegmentSyncComputeMessage message = new EnglishSegmentSyncComputeMessage(); message.setQuestionId(question.getId()); message.setOperatorId(event.getOperator() == null ? null : event.getOperator().getLoginUserId()); message.setRequestTime(System.currentTimeMillis()); rocketMqProducerService.sendNAsync( --- a/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentStateTxService.java +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentStateTxService.java @@ -5,17 +5,18 @@ import cn.jojo.cc.core.domain.question.library.QuestionDo; import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; /** 同库正文锁与派生状态事务;调用方必须先选定数据源。 */ @Service public class QuestionSegmentStateTxService { @Resource private QuestionSegmentCoreService segments; - @Transactional(rollbackFor = Exception.class) + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) public void invalidateAfterSave(Long id) { QuestionDo current = segments.lockQuestion(id); if (current == null) { return; } Integer type = SegmentContentTypeEnum.contentTypeOfQuestionType(current.getQuestionType()); if (type != null) { --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTxService.java @@ -0,0 +1,47 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.utils.MD5Util; +import cn.jojo.cc.common.enums.english.SegmentContentTypeEnum; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.util.Set; +import java.util.Objects; +import java.util.function.Function; + +/** 独立单库事务:代理退出后先回滚/释放题锁,调用者才允许记录失败。 */ +@Service +public class QuestionSegmentTxService { + @Resource private QuestionSegmentCoreService segments; + @Resource private SegmentContentSnapshotService content; + + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public T compute(QuestionDo expected, Function action) { + QuestionDo current = segments.lockQuestion(expected.getId()); + if (current == null || !Objects.equals(current.getUpdateTime(), expected.getUpdateTime())) { return null; } + return action.apply(current); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW, rollbackFor = Exception.class) + public void recordFailure(QuestionDo attempted, String md5, Set types, String error) { + QuestionDo current = segments.lockQuestion(attempted.getId()); + if (current == null || !Objects.equals(current.getUpdateTime(), attempted.getUpdateTime())) { return; } + // 同审计时间的正文也可能变化:可抽取时用同一正文指纹再拒绝过时失败登记。 + if (!Objects.equals(md5, MD5Util.creatMd5(""))) { + String currentMd5 = content.fingerprint(content.readTexts(current)); + if (!Objects.equals(md5, currentMd5)) { return; } + } + Set targets = types; + if (targets.isEmpty()) { + Integer fallback = SegmentContentTypeEnum.contentTypeOfQuestionType(current.getQuestionType()); + targets = Set.of(fallback == null ? SegmentContentTypeEnum.MATERIAL.getCode() : fallback); + } + for (Integer type : targets) { + segments.markFailedByQuestionId(current.getId(), type, current.getQuestionKey(), md5, + current.getUpdateTime(), error); + } + } +} --- /dev/null +++ b/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentContentSnapshotService.java @@ -0,0 +1,40 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.utils.MD5Util; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import jakarta.annotation.Resource; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Service; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +/** 计算、失败恢复和下发共用的正文/内容位指纹;不拿审计时间代替内容版本。 */ +@Service +public class SegmentContentSnapshotService { + @Resource private SegmentTextExtractor segmentTextExtractor; + + public Map> readTexts(QuestionDo question) { + Map> grouped = new TreeMap<>(); + if (question == null || question.getId() == null) { return grouped; } + Map> richTexts = segmentTextExtractor.extractRichTextsByContentType(question); + if (richTexts == null) { return grouped; } + 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; + } + + public String fingerprint(Map> grouped) { + List all = new ArrayList<>(); + new TreeMap<>(grouped).values().forEach(all::addAll); + return MD5Util.creatMd5(String.join("\n", all)); + } +} --- a/application/src/test/java/cn/jojo/cc/biz/service/english/dict/EnglishAudioPublishGuardRegressionTest.java +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/dict/EnglishAudioPublishGuardRegressionTest.java @@ -23,6 +23,24 @@ ReflectionTestUtils.setField(service, "englishDictWriteLockService", locks); ReflectionTestUtils.setField(service, "englishDictPublishProServiceHelper", helper); assertFalse(service.publishAudioWord(1L, null).isProdPublished()); verifyNoInteractions(helper); } + @Test void sameSecondManualTakeoverCannotBePublishedAsUntouchedAutoFill() { + var service = new EnglishPublishBizServiceImpl(); + var words = mock(EnglishWordCoreService.class); + var locks = mock(EnglishDictWriteLockService.class); + var helper = mock(EnglishDictPublishProServiceHelper.class); + var draft = new EnglishWordDo(); draft.setId(1L); draft.setWordNormalized("cat"); + draft.setStatus(0); draft.setHasPublished(0); + draft.setSource(cn.jojo.cc.common.enums.english.DictSourceEnum.SEGMENT_AUTOFILL.getCode()); + draft.setCreateTime(100L); draft.setUpdateTime(100L); draft.setAutoPublishEligible(0); + when(words.getById(1L, 0L)).thenReturn(draft); + when(locks.callWithWordLock(eq(1L), eq("cat"), any())) + .thenAnswer(i -> ((Supplier) i.getArgument(2)).get()); + ReflectionTestUtils.setField(service, "englishWordCoreService", words); + ReflectionTestUtils.setField(service, "englishDictWriteLockService", locks); + ReflectionTestUtils.setField(service, "englishDictPublishProServiceHelper", helper); + assertFalse(service.publishAutoFillWord(1L, null).isProdPublished()); + verifyNoInteractions(helper); + } } --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/dict/EnglishDictWriteTxServiceTest.java @@ -0,0 +1,795 @@ +package cn.jojo.cc.biz.service.english.dict; + +import cn.jojo.cc.biz.model.bo.english.EnglishPhraseSaveBo; +import cn.jojo.cc.biz.model.bo.english.EnglishPhraseVariantBo; +import cn.jojo.cc.biz.model.bo.english.EnglishWordSaveBo; +import cn.jojo.cc.biz.model.bo.english.EnglishWordTranslationBo; +import cn.jojo.cc.common.enums.english.DictSourceEnum; +import cn.jojo.cc.common.enums.english.EnglishPublishStatusEnum; +import cn.jojo.cc.common.enums.english.WordCompletenessEnum; +import cn.jojo.cc.common.exception.BusinessException; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.knowledge.LocalResourceDo; +import cn.jojo.cc.core.domain.english.EnglishPhraseDo; +import cn.jojo.cc.core.domain.english.EnglishPhraseVariantDo; +import cn.jojo.cc.core.domain.english.EnglishWordDo; +import cn.jojo.cc.core.domain.english.EnglishWordTranslationDo; +import cn.jojo.cc.core.service.english.EnglishPhraseCoreService; +import cn.jojo.cc.core.service.english.EnglishPhraseVariantCoreService; +import cn.jojo.cc.core.service.english.EnglishWordCoreService; +import cn.jojo.cc.core.service.english.EnglishWordTranslationCoreService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 描述:词典写入事务服务的校验门槛与差量同步测试。 + * + *

本类只写教研库,是人工保存、分词补录与首次刷数据三条链路的唯一写入口, + * 因此这里逐条钉住:三套口径各自的校验不放宽(人工 1~10 组翻译、补录允许空壳、导入只补亏)、 + * 已发布对象不可删除、子行只做差集逻辑删除(整表重插会打断已下发 segments 的引用)、 + * 编辑时来源与 has_published 不被锁外快照冲掉(否则删除闸门与后台出身显示都会失真)。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@SuppressWarnings("unchecked") +class EnglishDictWriteTxServiceTest { + + private static final long NEW_WORD_ID = 500L; + + private static final long NEW_PHRASE_ID = 600L; + + private static final long JY_WORD_ID = 100L; + + private static final long JY_PHRASE_ID = 110L; + + private static final long AUDIO_ID = 700L; + + @InjectMocks + private EnglishDictWriteTxService service; + + @Mock + private EnglishWordCoreService englishWordCoreService; + + @Mock + private EnglishWordTranslationCoreService englishWordTranslationCoreService; + + @Mock + private EnglishDictAudioResourceService englishDictAudioResourceService; + + @Mock + private EnglishPhraseCoreService englishPhraseCoreService; + + @Mock + private EnglishPhraseVariantCoreService englishPhraseVariantCoreService; + + private final LoginUserBo operator = new LoginUserBo(9L, "tester"); + + /** + * 音频必填(PRD)后,有人工保存路径的用例都得先过有效性校验; + * 用例内部再写 when(...).thenReturn(...) 会覆盖本桩(无效/已删音频场景仍可单独验)。 + */ + @BeforeEach + void stubAudioResourceExists() { + allowAudio(AUDIO_ID); + } + + /** + * 第3轮问题11 回归:两组翻译携带同一个已存在子行 ID 必须被拒——旧写法逐项只查归属, + * 两次 saveOrUpdate 打同一主键后写覆盖前写,接口返回成功而库里少一组。 + * + *

音频桩必须给出有效资源:否则用例会因为音频校验先失败而“因错误的原因通过”。

+ */ + @Test + @DisplayName("saveWord - 重复子行 ID 被拒,报的是子行重复而不是音频非法") + void saveWordRejectsDuplicatedTranslationRowId() { + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(List.of(validAudio(500L))); + + EnglishWordSaveBo bo = new EnglishWordSaveBo(); + bo.setWord("good"); + bo.setPhonetic("ɡʊd"); + bo.setAudioResourceId(500L); + bo.setTranslations(List.of(translation(7L, "adj.", "好的"), translation(7L, "n.", "好处"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.saveWord(bo, null, operator)); + + String message = error.getMessage(); + assertTrue(message != null && message.contains("Duplicated child row id"), message); + } + + /** + * 第四轮复审问题12 的姊妹缺口:运营删过的词不得被补录当成“不存在的词”重建。 + * 旧写法只认未删除行,于是同一个词库里会并存两行(运营删的那行 + 补录新建并发布的那行)。 + */ + @Test + @DisplayName("saveWordForAutoFill - 只有已删除同名行时原样带回,不新建不写翻译") + void autoFillReusesSoftDeletedRowInsteadOfCreatingDuplicate() { + EnglishWordDo deleted = new EnglishWordDo(); + deleted.setId(60L); + deleted.setWord("hippo"); + deleted.setWordNormalized("hippo"); + deleted.setDeleteTime(123L); + when(englishWordCoreService.getByNormalized("hippo", 0L)).thenReturn(null); + when(englishWordCoreService.getByNormalizedIgnoreDeleted("hippo")).thenReturn(deleted); + + EnglishWordDo saved = service.saveWordForAutoFill( + "hippo", "\u02c8h\u026ap\u0259\u028ap", 500L, List.of(), operator); + + assertSame(deleted, saved); + verify(englishWordCoreService, never()).saveOrUpdate(any()); + verify(englishWordTranslationCoreService, never()).saveOrUpdateBatch(any()); + } + + @Test + @DisplayName("saveWord - 新建:待发布、人工来源、完整性按落库值算、子表按序落库") + void saveWordCreatesNewWordWithComputedCompleteness() { + allowAudio(AUDIO_ID); + stubWordId(); + EnglishWordSaveBo bo = wordBo("good"); + bo.setPhonetic("ɡʊd"); + bo.setAudioResourceId(AUDIO_ID); + bo.setTranslations(List.of(translation(null, "adj.", "好的"), translation(null, "n.", "好处"))); + + EnglishWordDo saved = service.saveWord(bo, null, operator); + assertEquals(0, saved.getAutoPublishEligible()); + + assertEquals(Long.valueOf(NEW_WORD_ID), saved.getId()); + assertEquals("good", saved.getWordNormalized()); + assertEquals(EnglishPublishStatusEnum.UNPUBLISHED.getCode(), saved.getStatus()); + assertEquals(Integer.valueOf(0), saved.getHasPublished()); + assertEquals(DictSourceEnum.MANUAL.getCode(), saved.getSource()); + assertEquals(WordCompletenessEnum.COMPLETE.getCode(), saved.getCompleteness()); + assertEquals(Long.valueOf(0L), saved.getDeleteTime()); + assertEquals(Long.valueOf(9L), saved.getCreatorId()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(englishWordTranslationCoreService).saveOrUpdateBatch(captor.capture()); + List writes = captor.getValue(); + assertEquals(2, writes.size()); + assertEquals(Integer.valueOf(1), writes.get(0).getSortOrder()); + assertEquals(Integer.valueOf(2), writes.get(1).getSortOrder()); + assertEquals(Long.valueOf(NEW_WORD_ID), writes.get(0).getWordId()); + assertEquals(Long.valueOf(0L), writes.get(0).getDeleteTime()); + } + + @Test + @DisplayName("saveWord - 编辑:来源保留、has_published 不回写、完整性按库内原值合并") + void saveWordOnEditKeepsSourceAndHasPublishedAndMergesStoredAudio() { + EnglishWordDo existing = existingWord(); + EnglishWordSaveBo bo = wordBo("good"); + // 音标本次未提交(音频必填后不能再拿“不传音频”当未提交的例子),完整性判定必须取库内原值。 + bo.setPhonetic(null); + bo.setTranslations(List.of(translation(null, "adj.", "好的"))); + + existing.setAutoPublishEligible(1); + EnglishWordDo saved = service.saveWord(bo, existing, operator); + assertEquals(0, saved.getAutoPublishEligible(), "人工保存清除自动发布资格,与秒级审计时间无关"); + + assertEquals(Long.valueOf(JY_WORD_ID), saved.getId()); + // 来源保持补录出身:回落成人工会让系统自动发布的出身闸门失效。 + assertEquals(DictSourceEnum.SEGMENT_AUTOFILL.getCode(), saved.getSource()); + // has_published 只能由发布链路单向登记,锁外快照回写会把并发发布刚登记的“曾发布”冲掉。 + assertNull(saved.getHasPublished()); + // 本次没传音标,判定要用库内原值,否则完整词条会被误刷成缺失。 + assertEquals(WordCompletenessEnum.COMPLETE.getCode(), saved.getCompleteness()); + verify(englishWordCoreService).saveOrUpdate(any()); + } + + @Test + @DisplayName("saveWord - 空词条、内嵌换行、非法音频都要在写库前拒绝") + void saveWordRejectsBlankNewlineAndIllegalAudio() { + BusinessException blank = assertThrows(BusinessException.class, + () -> service.saveWord(new EnglishWordSaveBo(), null, operator)); + assertTrue(blank.getMessage().contains("English word is required"), blank.getMessage()); + + BusinessException newlineWord = assertThrows(BusinessException.class, + () -> service.saveWord(wordBo("go\ndown"), null, operator)); + assertTrue(newlineWord.getMessage().contains("cc_error_local.english.text.no.newline"), + newlineWord.getMessage()); + + EnglishWordSaveBo newlinePhonetic = wordBo("good"); + newlinePhonetic.setPhonetic("ɡʊd\r"); + BusinessException newline = assertThrows(BusinessException.class, + () -> service.saveWord(newlinePhonetic, null, operator)); + assertTrue(newline.getMessage().contains("cc_error_local.english.text.no.newline"), + newline.getMessage()); + + EnglishWordSaveBo imageAudio = wordBo("good"); + imageAudio.setAudioResourceId(AUDIO_ID); + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(List.of(audio(AUDIO_ID, "jpg"))); + BusinessException notAudio = assertThrows(BusinessException.class, + () -> service.saveWord(imageAudio, null, operator)); + assertTrue(notAudio.getMessage().contains("not a valid audio"), notAudio.getMessage()); + + // 已删除/不存在的资源与「类型不对」同一口径拒绝(listAliveByIds 查不到即已删除)。 + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(List.of()); + BusinessException missingAudio = assertThrows(BusinessException.class, + () -> service.saveWord(imageAudio, null, operator)); + assertTrue(missingAudio.getMessage().contains("resource is missing"), missingAudio.getMessage()); + } + + @Test + @DisplayName("save - 人工新增/编辑缺音频一律拒绝,导入建行不受影响") + void saveRejectsMissingAudioForManualEntries() { + EnglishWordSaveBo word = wordBo("good"); + word.setAudioResourceId(null); + BusinessException wordError = assertThrows(BusinessException.class, + () -> service.saveWord(word, null, operator)); + assertTrue(wordError.getMessage().contains("Audio is required"), wordError.getMessage()); + + EnglishPhraseSaveBo phrase = phraseBo("take off"); + phrase.setAudioResourceId(null); + BusinessException phraseError = assertThrows(BusinessException.class, + () -> service.savePhrase(phrase, null, operator)); + assertTrue(phraseError.getMessage().contains("Audio is required"), phraseError.getMessage()); + + EnglishPhraseSaveBo variantMissingAudio = phraseBo("take off"); + variantMissingAudio.setVariants(List.of(variant(null, "off", "ɒf", null))); + BusinessException variantError = assertThrows(BusinessException.class, + () -> service.savePhrase(variantMissingAudio, null, operator)); + assertTrue(variantError.getMessage().contains("Variant text, phonetic and audio"), + variantError.getMessage()); + + // 导入建行走独立入口、不经人工校验:音频缺失仍能落行,由回填任务补齐。 + stubPhraseId(); + EnglishPhraseSaveBo imported = phraseBo("look up"); + imported.setAudioResourceId(null); + imported.setVariants(List.of(variant(null, "looking up", "lʊkɪŋ ʌp", null))); + when(englishPhraseCoreService.getByNormalized(anyString(), any())).thenReturn(null); + EnglishPhraseDo created = service.savePhraseForImport(imported, operator); + assertNull(created.getAudioResourceId()); + } + + @Test + @DisplayName("saveWord - 翻译组数、空译文与未登记词性都拒绝") + void saveWordRejectsIllegalTranslationGroups() { + EnglishWordSaveBo empty = new EnglishWordSaveBo(); + empty.setWord("good"); + empty.setAudioResourceId(AUDIO_ID); + empty.setTranslations(List.of()); + BusinessException noTranslation = assertThrows(BusinessException.class, + () -> service.saveWord(empty, null, operator)); + assertTrue(noTranslation.getMessage().contains("1 to 10 groups"), noTranslation.getMessage()); + + EnglishWordSaveBo tooMany = new EnglishWordSaveBo(); + tooMany.setWord("good"); + tooMany.setAudioResourceId(AUDIO_ID); + tooMany.setTranslations(java.util.stream.IntStream.range(0, 11) + .mapToObj(index -> translation(null, "adj.", "好的" + index)) + .toList()); + BusinessException overLimit = assertThrows(BusinessException.class, + () -> service.saveWord(tooMany, null, operator)); + assertTrue(overLimit.getMessage().contains("1 to 10 groups"), overLimit.getMessage()); + + EnglishWordSaveBo blankTranslation = new EnglishWordSaveBo(); + blankTranslation.setWord("good"); + blankTranslation.setAudioResourceId(AUDIO_ID); + blankTranslation.setTranslations(List.of(translation(null, "adj.", " "))); + BusinessException blank = assertThrows(BusinessException.class, + () -> service.saveWord(blankTranslation, null, operator)); + assertTrue(blank.getMessage().contains("1 to 10 groups"), blank.getMessage()); + + EnglishWordSaveBo unknownPos = new EnglishWordSaveBo(); + unknownPos.setWord("good"); + unknownPos.setAudioResourceId(AUDIO_ID); + unknownPos.setTranslations(List.of(translation(null, "phr.", "短语词性未登记"))); + BusinessException pos = assertThrows(BusinessException.class, + () -> service.saveWord(unknownPos, null, operator)); + assertEquals("cc_error_local.english.word.pos.illegal", pos.getMessage()); + } + + @Test + @DisplayName("saveWord - 归一化文本撞库拒绝,提示改搜索后编辑") + void saveWordRejectsDuplicatedText() { + when(englishWordCoreService.existsByNormalizedTextExcludingId(any(), any(), any())).thenReturn(true); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.saveWord(wordBo("good"), null, operator)); + + assertTrue(error.getMessage().contains("Duplicated english word"), error.getMessage()); + verify(englishWordCoreService, never()).saveOrUpdate(any()); + } + + @Test + @DisplayName("saveWord - 同一保存内 sortOrder 必须唯一") + void saveWordRejectsDuplicatedSortOrder() { + stubWordId(); + EnglishWordSaveBo bo = wordBo("good"); + EnglishWordTranslationBo first = translation(null, "adj.", "好的"); + first.setSortOrder(1); + EnglishWordTranslationBo second = translation(null, "n.", "好处"); + second.setSortOrder(1); + bo.setTranslations(List.of(first, second)); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.saveWord(bo, null, operator)); + + assertTrue(error.getMessage().contains("sortOrder must be unique in group"), error.getMessage()); + verify(englishWordTranslationCoreService, never()).saveOrUpdateBatch(any()); + } + + @Test + @DisplayName("saveWord - 传入不属于本词的子行 ID 被拒,不能把别的词的翻译挪过来") + void saveWordRejectsChildRowOwnedByOtherWord() { + stubWordId(); + EnglishWordTranslationDo foreign = translationOf(77L, 999L); + when(englishWordTranslationCoreService.getById(77L)).thenReturn(foreign); + EnglishWordSaveBo bo = wordBo("good"); + bo.setTranslations(List.of(translation(77L, "adj.", "好的"))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.saveWord(bo, null, operator)); + + assertTrue(error.getMessage().contains("does not belong to this word"), error.getMessage()); + } + + @Test + @DisplayName("saveWord - 本次未提交的旧翻译只逻辑删除差集,不整表重插") + void saveWordSoftDeletesTranslationRowsRemovedFromPayload() { + stubWordId(); + EnglishWordDo existing = existingWord(); + EnglishWordTranslationDo stale = translationOf(61L, JY_WORD_ID); + when(englishWordTranslationCoreService.listByWordIds(anyList(), eq(0L))).thenReturn(List.of(stale)); + + service.saveWord(wordBo("good"), existing, operator); + + // 已下发的 segments 引用子行 ID,整表软删重插会打断这些引用。 + verify(englishWordTranslationCoreService).softDeleteByDo(stale); + verify(englishWordTranslationCoreService).saveOrUpdateBatch(any()); + } + + @Test + @DisplayName("saveWordForAutoFill - 空文本拒绝,活行直接复用不写") + void autoFillRejectsBlankTextAndReusesLiveRow() { + BusinessException blank = assertThrows(BusinessException.class, + () -> service.saveWordForAutoFill(" ", null, null, null, operator)); + assertTrue(blank.getMessage().contains("English word is required"), blank.getMessage()); + + EnglishWordDo existing = existingWord(); + when(englishWordCoreService.getByNormalized("good", 0L)).thenReturn(existing); + assertSame(existing, service.saveWordForAutoFill("good", null, null, null, operator)); + verify(englishWordCoreService, never()).saveOrUpdate(any()); + } + + @Test + @DisplayName("saveWordForAutoFill - 允许空壳落库,完整性自然降级为缺失") + void autoFillCreatesIncompleteShellWithoutTranslations() { + stubWordId(); + + EnglishWordDo created = service.saveWordForAutoFill("hippo", null, null, null, operator); + + assertEquals(Long.valueOf(NEW_WORD_ID), created.getId()); + assertEquals(DictSourceEnum.SEGMENT_AUTOFILL.getCode(), created.getSource()); + assertEquals(EnglishPublishStatusEnum.UNPUBLISHED.getCode(), created.getStatus()); + assertEquals(Integer.valueOf(0), created.getHasPublished()); + assertEquals(WordCompletenessEnum.INCOMPLETE.getCode(), created.getCompleteness()); + verify(englishWordTranslationCoreService, never()).saveOrUpdateBatch(any()); + } + + @Test + @DisplayName("saveWordForAutoFill - 有翻译时子行挂在新建主行下") + void autoFillSavesTranslationsForNewRow() { + stubWordId(); + + service.saveWordForAutoFill("good", "ɡʊd", AUDIO_ID, + List.of(translation(null, "adj.", "好的")), operator); + + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(englishWordTranslationCoreService).saveOrUpdateBatch(captor.capture()); + List writes = captor.getValue(); + assertEquals(1, writes.size()); + assertEquals(Long.valueOf(NEW_WORD_ID), writes.get(0).getWordId()); + assertEquals("adj.", writes.get(0).getPos()); + } + + @Test + @DisplayName("deleteWord - 主行与翻译一起逻辑删除,返回事务内读到的那一行") + void deleteWordLogicalDeletesWordAndChildren() { + EnglishWordDo existing = existingWord(); + EnglishWordTranslationDo child = translationOf(61L, JY_WORD_ID); + when(englishWordCoreService.getById(JY_WORD_ID, 0L)).thenReturn(existing); + when(englishWordTranslationCoreService.listByWordIds(anyList(), eq(0L))).thenReturn(List.of(child)); + + EnglishWordDo deleted = service.deleteWord(JY_WORD_ID, null); + + // 调用方的缓存失效必须用这个返回值:删后按“未删除行”回查必空。 + assertSame(existing, deleted); + verify(englishWordCoreService).softDeleteByDo(existing); + verify(englishWordTranslationCoreService).softDeleteBatchByDo(List.of(child)); + } + + @Test + @DisplayName("deleteWord - 不存在与已发布都拒绝删除") + void deleteWordRejectsMissingAndPublishedRows() { + when(englishWordCoreService.getById(JY_WORD_ID, 0L)).thenReturn(null); + BusinessException missing = assertThrows(BusinessException.class, + () -> service.deleteWord(JY_WORD_ID, operator)); + assertTrue(missing.getMessage().contains("English word not found"), missing.getMessage()); + + EnglishWordDo published = existingWord(); + published.setHasPublished(1); + when(englishWordCoreService.getById(JY_WORD_ID, 0L)).thenReturn(published); + BusinessException forbidden = assertThrows(BusinessException.class, + () -> service.deleteWord(JY_WORD_ID, operator)); + assertTrue(forbidden.getMessage().contains("deletion is not allowed"), forbidden.getMessage()); + verify(englishWordCoreService, never()).softDeleteByDo(any()); + } + + /** + * 变形文本不得与原形相同:跨条目的同文本由 findConflicts 拦,但它按 excludePhraseId 把整个短语 + * (原形 + 全部变形)排除在比对之外,于是「自己原形 ↔ 自己变形」这一组会漏过去——落库后索引里 + * 会多出一条永远命不中的死项,运营在弹窗里也会看到原形和变形一模一样。 + */ + @Test + @DisplayName("savePhrase - 变形与原形同文本被拒") + void savePhraseRejectsVariantEqualToItsOwnPhrase() { + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setVariants(List.of(variant(null, "take off", "teɪk ɒf", AUDIO_ID))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.savePhrase(bo, null, operator)); + + assertTrue(error.getMessage().contains("Variant text must differ from the phrase itself"), + error.getMessage()); + verify(englishPhraseCoreService, never()).saveOrUpdate(any()); + } + + /** + * 导入入口不走 validatePhrase,是同一条 assertPhraseTextsFree 兜住的它—— + * 若把这道校验加在 validatePhrase 里,XxlJob 导入这条旁路会漏。 + */ + @Test + @DisplayName("savePhraseForImport - 变形与原形同文本同样被拒") + void savePhraseForImportRejectsVariantEqualToItsOwnPhrase() { + when(englishPhraseCoreService.getByNormalized(anyString(), any())).thenReturn(null); + EnglishPhraseSaveBo bo = phraseBo("look up"); + bo.setVariants(List.of(variant(null, "look up", "lʊk ʌp", null))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.savePhraseForImport(bo, operator)); + + assertTrue(error.getMessage().contains("Variant text must differ from the phrase itself"), + error.getMessage()); + verify(englishPhraseCoreService, never()).saveOrUpdate(any()); + } + + @Test + @DisplayName("savePhrase - 新建短语:待发布、人工来源、变形子表按序落库") + void savePhraseCreatesNewPhraseWithVariants() { + long variantAudio = 701L; + long otherVariantAudio = 702L; + when(englishDictAudioResourceService.listAliveByIds(any())) + .thenReturn(List.of(validAudio(AUDIO_ID), validAudio(variantAudio), validAudio(otherVariantAudio))); + stubPhraseId(); + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setPhonetic("teɪk ɒf"); + bo.setAudioResourceId(AUDIO_ID); + bo.setVariants(List.of( + variant(null, "off", "ɒf", variantAudio), + variant(null, "takes off", "teɪks ɒf", otherVariantAudio))); + + EnglishPhraseDo saved = service.savePhrase(bo, null, operator); + + assertEquals(Long.valueOf(NEW_PHRASE_ID), saved.getId()); + assertEquals("take off", saved.getPhraseNormalized()); + assertEquals(DictSourceEnum.MANUAL.getCode(), saved.getSource()); + assertEquals(EnglishPublishStatusEnum.UNPUBLISHED.getCode(), saved.getStatus()); + assertEquals(Integer.valueOf(0), saved.getHasPublished()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(englishPhraseVariantCoreService).saveOrUpdateBatch(captor.capture()); + List writes = captor.getValue(); + assertEquals(2, writes.size()); + assertEquals(Integer.valueOf(1), writes.get(0).getSortOrder()); + assertEquals(Integer.valueOf(2), writes.get(1).getSortOrder()); + assertEquals("off", writes.get(0).getVariantNormalized()); + assertEquals(Long.valueOf(NEW_PHRASE_ID), writes.get(0).getPhraseId()); + assertEquals(Long.valueOf(variantAudio), writes.get(0).getAudioResourceId()); + assertEquals(Long.valueOf(otherVariantAudio), writes.get(1).getAudioResourceId()); + } + + @Test + @DisplayName("savePhrase - 编辑保留来源,变形子表只差集逻辑删除") + void savePhraseOnEditKeepsSourceAndSoftDeletesRemovedVariant() { + stubPhraseId(); + EnglishPhraseDo existing = existingPhrase(); + EnglishPhraseVariantDo stale = variantDo(81L, JY_PHRASE_ID); + when(englishPhraseVariantCoreService.listByPhraseId(any(), eq(0L))) + .thenReturn(List.of(stale)); + EnglishPhraseSaveBo bo = phraseBo("take off"); + + EnglishPhraseDo saved = service.savePhrase(bo, existing, operator); + + assertEquals(Long.valueOf(JY_PHRASE_ID), saved.getId()); + assertEquals(DictSourceEnum.SEGMENT_AUTOFILL.getCode(), saved.getSource()); + verify(englishPhraseVariantCoreService).softDeleteByDo(stale); + } + + @Test + @DisplayName("savePhrase - 变形缺音标、组内重复、超过 10 组都拒绝") + void savePhraseRejectsIncompleteOrDuplicatedVariants() { + EnglishPhraseSaveBo noPhonetic = phraseBo("take off"); + noPhonetic.setVariants(List.of(variant(null, "off", " ", null))); + BusinessException incomplete = assertThrows(BusinessException.class, + () -> service.savePhrase(noPhonetic, null, operator)); + assertTrue(incomplete.getMessage().contains("Variant text, phonetic and audio are required"), + incomplete.getMessage()); + + EnglishPhraseSaveBo duplicated = phraseBo("take off"); + duplicated.setVariants(List.of( + variant(null, "off", "ɒf", AUDIO_ID), + variant(null, " OFF ", "ɒf", AUDIO_ID))); + BusinessException duplicate = assertThrows(BusinessException.class, + () -> service.savePhrase(duplicated, null, operator)); + assertEquals("cc_error_local.english.phrase.variant.duplicate", duplicate.getMessage()); + + EnglishPhraseSaveBo tooMany = phraseBo("take off"); + tooMany.setVariants(java.util.stream.IntStream.range(0, 11) + .mapToObj(index -> variant(null, "variant " + index, "ɒf", null)) + .toList()); + BusinessException overLimit = assertThrows(BusinessException.class, + () -> service.savePhrase(tooMany, null, operator)); + assertTrue(overLimit.getMessage().contains("at most 10 groups"), overLimit.getMessage()); + } + + @Test + @DisplayName("savePhrase - 原形撞库与变形占用别人的文本都拒绝") + void savePhraseRejectsDuplicatedAndConflictingTexts() { + when(englishPhraseCoreService.existsByNormalizedTextExcludingId(any(), any(), any())).thenReturn(true); + BusinessException duplicated = assertThrows(BusinessException.class, + () -> service.savePhrase(phraseBo("take off"), null, operator)); + assertTrue(duplicated.getMessage().contains("Duplicated english phrase"), duplicated.getMessage()); + + when(englishPhraseCoreService.existsByNormalizedTextExcludingId(any(), any(), any())).thenReturn(false); + when(englishPhraseCoreService.findConflicts(any(), any())).thenReturn(java.util.Set.of("aim at")); + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setVariants(List.of(variant(null, "aim at", "eɪm", AUDIO_ID))); + BusinessException conflict = assertThrows(BusinessException.class, + () -> service.savePhrase(bo, null, operator)); + assertEquals("cc_error_local.english.phrase.conflict", conflict.getMessage()); + } + + @Test + @DisplayName("savePhrase - 传入不属于本短语的变形行 ID 被拒") + void savePhraseRejectsVariantChildOwnedByOtherPhrase() { + stubPhraseId(); + when(englishPhraseVariantCoreService.getById(88L)).thenReturn(variantDo(88L, 999L)); + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setVariants(List.of(variant(88L, "off", "ɒf", AUDIO_ID))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.savePhrase(bo, null, operator)); + + assertTrue(error.getMessage().contains("does not belong to this phrase"), error.getMessage()); + } + + @Test + @DisplayName("deletePhrase - 主行与变形一起逻辑删除;不存在与已发布拒绝") + void deletePhraseLogicalDeletesVariantsAndRejectsPublished() { + EnglishPhraseDo existing = existingPhrase(); + EnglishPhraseVariantDo child = variantDo(81L, JY_PHRASE_ID); + when(englishPhraseCoreService.getById(JY_PHRASE_ID, 0L)).thenReturn(existing); + when(englishPhraseVariantCoreService.listByPhraseId(any(), eq(0L))).thenReturn(List.of(child)); + + service.deletePhrase(JY_PHRASE_ID, null); + + verify(englishPhraseCoreService).softDeleteByDo(existing); + verify(englishPhraseVariantCoreService).softDeleteBatchByDo(List.of(child)); + + when(englishPhraseCoreService.getById(JY_PHRASE_ID, 0L)).thenReturn(null); + BusinessException missing = assertThrows(BusinessException.class, + () -> service.deletePhrase(JY_PHRASE_ID, operator)); + assertTrue(missing.getMessage().contains("English phrase not found"), missing.getMessage()); + + EnglishPhraseDo published = existingPhrase(); + published.setHasPublished(1); + when(englishPhraseCoreService.getById(JY_PHRASE_ID, 0L)).thenReturn(published); + BusinessException forbidden = assertThrows(BusinessException.class, + () -> service.deletePhrase(JY_PHRASE_ID, operator)); + assertTrue(forbidden.getMessage().contains("deletion is not allowed"), forbidden.getMessage()); + } + + @Test + @DisplayName("saveWordForImport - 已存在一律不动,新建记刷数据来源") + void saveWordForImportSkipsExistingAndCreatesImportSource() { + when(englishWordCoreService.getByNormalized("good", 0L)).thenReturn(existingWord()); + + assertNull(service.saveWordForImport("good", null, null, List.of(), operator)); + // 刷数据只补亏,不拿机器数据盖运营手工内容。 + verify(englishWordCoreService, never()).saveOrUpdate(any()); + + BusinessException blank = assertThrows(BusinessException.class, + () -> service.saveWordForImport(" ", null, null, List.of(), operator)); + assertTrue(blank.getMessage().contains("English word is required"), blank.getMessage()); + + when(englishWordCoreService.getByNormalized("good", 0L)).thenReturn(null); + stubWordId(); + EnglishWordDo created = service.saveWordForImport("good", "ɡʊd", AUDIO_ID, + List.of(translation(null, "adj.", "好的")), operator); + + assertEquals(DictSourceEnum.IMPORT.getCode(), created.getSource()); + assertEquals(EnglishPublishStatusEnum.UNPUBLISHED.getCode(), created.getStatus()); + assertEquals(Integer.valueOf(0), created.getHasPublished()); + assertEquals(WordCompletenessEnum.COMPLETE.getCode(), created.getCompleteness()); + } + + @Test + @DisplayName("savePhraseForImport - 已存在不动;新建记刷数据来源并把变形挂到新主行") + void savePhraseForImportSkipsExistingAndLinksVariants() { + long variantAudio = 701L; + when(englishDictAudioResourceService.listAliveByIds(any())) + .thenReturn(List.of(validAudio(AUDIO_ID), validAudio(variantAudio))); + stubPhraseId(); + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setAudioResourceId(AUDIO_ID); + bo.setVariants(List.of(variant(null, "off", "ɒf", variantAudio))); + + EnglishPhraseDo created = service.savePhraseForImport(bo, operator); + + assertEquals(DictSourceEnum.IMPORT.getCode(), created.getSource()); + assertEquals(EnglishPublishStatusEnum.UNPUBLISHED.getCode(), created.getStatus()); + assertEquals(Integer.valueOf(0), created.getHasPublished()); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(englishPhraseVariantCoreService).saveOrUpdateBatch(captor.capture()); + List writes = captor.getValue(); + assertEquals(1, writes.size()); + assertEquals(Long.valueOf(NEW_PHRASE_ID), writes.get(0).getPhraseId()); + + when(englishPhraseCoreService.getByNormalized("take off", 0L)).thenReturn(created); + assertNull(service.savePhraseForImport(bo, operator)); + } + + @Test + @DisplayName("savePhraseForImport - 变形占用别人的文本仍拒绝(导入不放宽查重)") + void savePhraseForImportKeepsConflictCheck() { + when(englishPhraseCoreService.getByNormalized(anyString(), any())).thenReturn(null); + when(englishPhraseCoreService.findConflicts(any(), any())).thenReturn(java.util.Set.of("aim at")); + EnglishPhraseSaveBo bo = phraseBo("take off"); + bo.setVariants(List.of(variant(null, "aim at", "eɪm", null))); + + BusinessException error = assertThrows(BusinessException.class, + () -> service.savePhraseForImport(bo, operator)); + + assertEquals("cc_error_local.english.phrase.conflict", error.getMessage()); + } + + private void stubWordId() { + when(englishWordCoreService.getByNormalized(anyString(), any())).thenReturn(null); + when(englishWordCoreService.getByNormalizedIgnoreDeleted(anyString())).thenReturn(null); + when(englishWordCoreService.saveOrUpdate(any())).thenAnswer(invocation -> { + EnglishWordDo word = invocation.getArgument(0); + if (word.getId() == null) { + word.setId(NEW_WORD_ID); + } + return true; + }); + } + + private void stubPhraseId() { + when(englishPhraseCoreService.saveOrUpdate(any())).thenAnswer(invocation -> { + EnglishPhraseDo phrase = invocation.getArgument(0); + if (phrase.getId() == null) { + phrase.setId(NEW_PHRASE_ID); + } + return true; + }); + } + + private void allowAudio(Long... ids) { + List resources = java.util.Arrays.stream(ids).map(this::validAudio).toList(); + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(resources); + } + + private EnglishWordDo existingWord() { + EnglishWordDo word = new EnglishWordDo(); + word.setId(JY_WORD_ID); + word.setWord("good"); + word.setWordNormalized("good"); + word.setPhonetic("ɡʊd"); + word.setAudioResourceId(AUDIO_ID); + word.setSource(DictSourceEnum.SEGMENT_AUTOFILL.getCode()); + word.setHasPublished(0); + word.setStatus(EnglishPublishStatusEnum.PUBLISHED.getCode()); + return word; + } + + private EnglishPhraseDo existingPhrase() { + EnglishPhraseDo phrase = new EnglishPhraseDo(); + phrase.setId(JY_PHRASE_ID); + phrase.setPhrase("take off"); + phrase.setPhraseNormalized("take off"); + phrase.setTranslation("起飞"); + phrase.setSource(DictSourceEnum.SEGMENT_AUTOFILL.getCode()); + phrase.setHasPublished(0); + phrase.setStatus(EnglishPublishStatusEnum.PUBLISHED.getCode()); + return phrase; + } + + private EnglishWordSaveBo wordBo(String word) { + EnglishWordSaveBo bo = new EnglishWordSaveBo(); + bo.setWord(word); + // 音标与音频在人工保存路径都是必填,默认给上,需要验证缺项的用例自行置空。 + bo.setPhonetic("ɡʊd"); + bo.setAudioResourceId(AUDIO_ID); + bo.setTranslations(List.of(translation(null, "adj.", "好的"))); + return bo; + } + + private EnglishPhraseSaveBo phraseBo(String phrase) { + EnglishPhraseSaveBo bo = new EnglishPhraseSaveBo(); + bo.setPhrase(phrase); + bo.setPhonetic("teɪk ɒf"); + bo.setAudioResourceId(AUDIO_ID); + bo.setTranslation("起飞"); + return bo; + } + + private EnglishWordTranslationBo translation(Long id, String pos, String translation) { + EnglishWordTranslationBo item = new EnglishWordTranslationBo(); + item.setId(id); + item.setPos(pos); + item.setTranslation(translation); + return item; + } + + private EnglishPhraseVariantBo variant(Long id, String text, String phonetic, Long audioId) { + EnglishPhraseVariantBo item = new EnglishPhraseVariantBo(); + item.setId(id); + item.setVariantText(text); + item.setPhonetic(phonetic); + item.setAudioResourceId(audioId); + return item; + } + + private EnglishWordTranslationDo translationOf(Long id, Long wordId) { + EnglishWordTranslationDo item = new EnglishWordTranslationDo(); + item.setId(id); + item.setWordId(wordId); + item.setPos("adj."); + item.setTranslation("好的"); + return item; + } + + private EnglishPhraseVariantDo variantDo(Long id, Long phraseId) { + EnglishPhraseVariantDo item = new EnglishPhraseVariantDo(); + item.setId(id); + item.setPhraseId(phraseId); + item.setVariantText("off"); + item.setVariantNormalized("off"); + return item; + } + + private LocalResourceDo validAudio(Long id) { + return audio(id, "mp3"); + } + + private LocalResourceDo audio(Long id, String fileType) { + LocalResourceDo resource = new LocalResourceDo(); + resource.setId(id); + resource.setFileType(fileType); + return resource; + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceTest.java @@ -0,0 +1,546 @@ +package cn.jojo.cc.biz.service.english.dict; + +import cn.jojo.cc.biz.cache.english.EnglishPhraseIndexCacheService; +import cn.jojo.cc.biz.model.bo.english.PublishResultBo; +import cn.jojo.cc.biz.service.component.AliyunRocketMQService; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.english.EnglishPublishStatusEnum; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.knowledge.LocalResourceDo; +import cn.jojo.cc.core.domain.english.EnglishPhraseDo; +import cn.jojo.cc.core.domain.english.EnglishPhraseVariantDo; +import cn.jojo.cc.core.domain.english.EnglishWordDo; +import cn.jojo.cc.core.domain.english.EnglishWordTranslationDo; +import cn.jojo.cc.core.service.english.EnglishPhraseCoreService; +import cn.jojo.cc.core.service.english.EnglishPhraseVariantCoreService; +import cn.jojo.cc.core.service.english.EnglishWordCoreService; +import cn.jojo.cc.core.service.english.EnglishWordTranslationCoreService; +import org.junit.jupiter.api.BeforeEach; +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.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +/** + * 描述:英文词典跨库发布测试。 + * + *

重点锁住四件事:PROD 侧 ID 只能递减一次且不得改写教研库入参、发布快照必须显式带 + * {@code status=PUBLISHED/has_published=1}、发布期间的新编辑不得被旧发布标成已发布、 + * 缓存失效只清正式库不能顺带清空教研库文本映射。

+ */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class EnglishPublishBizServiceTest { + + private static final long JY_ID = 100_000_001L; + + private static final long PROD_ID = 1L; + + private static final long JY_AUDIO_ID = 100_000_500L; + + private static final long JY_TRANSLATION_ID = 100_000_002L; + + @InjectMocks + private EnglishPublishBizServiceImpl publishService; + + @Mock + private EnglishWordCoreService englishWordCoreService; + + @Mock + private EnglishWordTranslationCoreService englishWordTranslationCoreService; + + @Mock + private EnglishPhraseCoreService englishPhraseCoreService; + + @Mock + private EnglishPhraseVariantCoreService englishPhraseVariantCoreService; + + @Mock + private EnglishDictAudioResourceService englishDictAudioResourceService; + + @Mock + private EnglishDictWriteLockService englishDictWriteLockService; + + @Mock + private EnglishDictPublishProServiceHelper englishDictPublishProServiceHelper; + + + @Mock + private EnglishPhraseIndexCacheService englishPhraseIndexCacheService; + + @Mock + private AliyunRocketMQService rocketMqProducerService; + + private final LoginUserBo operator = new LoginUserBo(9L, "tester"); + + /** 短语锁实参现场记录:锁服务在 setUp 里已被打桩,用 verify 取参会把打桩调用一起算进来。 */ + private final List phraseLockEntityIds = new java.util.ArrayList<>(); + + private final List phraseLockTexts = new java.util.ArrayList<>(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + // 锁服务透传:让被锁包裹的动作真实执行,才能断言锁内逻辑。新签名首参为实体 ID(发布传 wordId/phraseId)。 + when(englishDictWriteLockService.callWithWordLock(nullable(Long.class), anyString(), any(Supplier.class))) + .thenAnswer(invocation -> ((Supplier) invocation.getArgument(2)).get()); + when(englishDictWriteLockService.callWithPhraseLocks(nullable(Long.class), anyString(), any(), any(Supplier.class))) + .thenAnswer(invocation -> { + phraseLockEntityIds.add(invocation.getArgument(0)); + phraseLockTexts.add(invocation.getArgument(2)); + return ((Supplier) invocation.getArgument(3)).get(); + }); + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(List.of(audio(JY_AUDIO_ID))); + } + + @Test + void publishWordPassesJySnapshotIntoProdHelperAndKeepsInputIds() { + EnglishWordDo jyWord = word(); + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(jyWord); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)) + .thenReturn(List.of(translation(JY_TRANSLATION_ID))); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + ArgumentCaptor wordCaptor = ArgumentCaptor.forClass(EnglishWordDo.class); + ArgumentCaptor resourceCaptor = ArgumentCaptor.forClass(List.class); + verify(englishDictPublishProServiceHelper).publishWordInPro(wordCaptor.capture(), anyList(), + resourceCaptor.capture(), eq(operator)); + // 进入 PROD 事务的入参必须仍是教研库 ID:递减只在 PROD 侧做一次,禁止提前减导致双重递减。 + assertEquals(JY_ID, wordCaptor.getValue().getId()); + assertEquals(JY_AUDIO_ID, resourceCaptor.getValue().stream() + .map(item -> ((LocalResourceDo) item).getId()).findFirst().orElse(null)); + assertEquals(JY_ID, jyWord.getId()); + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + assertTrue(result.isJyStatusConfirmed()); + } + + @Test + void publishWordWritesBackStatusOnlyWhenRevisionUnchangedAndRegistersHasPublishedOtherwise() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + // 发布期间用户又保存了 v2:条件回写不命中,JY 必须保持待发布。 + when(englishWordCoreService.updatePublishStatusIfUnmodified(eq(JY_ID), + eq(EnglishPublishStatusEnum.PUBLISHED.getCode()), eq(1), eq(1_700_000_000L), + eq(9L))).thenReturn(false); + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + assertFalse(result.isJyStatusConfirmed()); + assertTrue(result.isContentChangedDuringPublish()); + // 但「曾发布」事实必须独立幂等登记,否则首次发布期间被编辑会永久丢掉历史发布语义(问题12)。 + verify(englishWordCoreService).registerHasPublished(JY_ID, 9L); + } + + @Test + void publishWordInvalidatesOnlyProdCacheAndKeepsTeachingTextMapping() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + publishService.publishWord(JY_ID, operator); + + // 只失效一次,且落在正式库 ID 上:发布不得顺带清空教研库的文本→ID 映射。 + org.mockito.Mockito.verifyNoInteractions(rocketMqProducerService); + verifyNoMoreInteractions(rocketMqProducerService); + } + + @Test + void publishWordLeavesRefreshToPendingRowsAndDoesNotBumpFullSignal() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + // 单词发布不再在请求线程内回刷,也不 bump 全量刷新信号: + // 高频词全量重分词代价过大;B′ 后无需回刷——下次下发按 PROD 词典读时解析自然恢复可点。 + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + } + + @Test + void publishMissingWordFailsBeforeTouchingProd() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(null); + + assertThrows(RuntimeException.class, () -> publishService.publishWord(JY_ID, operator)); + + verify(englishDictPublishProServiceHelper, never()).publishWordInPro(any(), anyList(), anyList(), any()); + } + + @Test + void publishWordFailsWhenAudioResourceMissing() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + when(englishDictAudioResourceService.listAliveByIds(any())).thenReturn(List.of()); + + assertThrows(RuntimeException.class, () -> publishService.publishWord(JY_ID, operator)); + + verify(englishDictPublishProServiceHelper, never()).publishWordInPro(any(), anyList(), anyList(), any()); + } + + @Test + void publishPhraseRebuildsProdIndexAndBumpsRefreshSignal() { + long jyPhraseId = 100_000_010L; + cn.jojo.cc.core.domain.english.EnglishPhraseDo phrase = + new cn.jojo.cc.core.domain.english.EnglishPhraseDo(); + phrase.setId(jyPhraseId); + phrase.setPhraseNormalized("look up"); + phrase.setUpdateTime(1_700_000_000L); + when(englishPhraseCoreService.getById(jyPhraseId, 0L)).thenReturn(phrase); + when(englishPhraseVariantCoreService.listByPhraseId(jyPhraseId, 0L)).thenReturn(List.of()); + when(englishPhraseCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + PublishResultBo result = publishService.publishPhrase(jyPhraseId, operator); + + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + org.mockito.Mockito.verifyNoInteractions(englishPhraseIndexCacheService); + // 短语漏命中不留痕,恢复统一交 bump 触发的 09 全量重分词,发布线程不再同步回刷。 + org.mockito.Mockito.verifyNoInteractions(rocketMqProducerService); + } + + /** + * 第四轮复审问题02 回归:PROD 已提交后 JY 状态回写抛异常(而非返回 false)时, + * 曾发布保护与 PROD 失效都必须照常执行:回写 false 和抛异常是两条路径, + * 只覆盖前者的测试挡不住“删除闸门放行 + 旧缓存不清理”的连锁后果。 + */ + @Test + void publishWordJyWritebackExceptionStillRegistersHasPublishedAndInvalidatesProd() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenThrow(new RuntimeException("db down")); + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + // 不因回写异常而上抛:PROD 已提交,发布结果就是成功。 + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + assertFalse(result.isJyStatusConfirmed()); + // 曾发布保护必须登记(只登记一次:异常分支走不进返回 false 的既有登记路径)。 + verify(englishWordCoreService).registerHasPublished(JY_ID, 9L); + // PROD 失效由提交事实驱动,回写异常不得跳过。 + org.mockito.Mockito.verifyNoInteractions(rocketMqProducerService); + } + + @Test + void publishPhraseJyWritebackExceptionStillInvalidatesProdAndBumpsRefresh() { + long jyPhraseId = 100_000_010L; + cn.jojo.cc.core.domain.english.EnglishPhraseDo phrase = + new cn.jojo.cc.core.domain.english.EnglishPhraseDo(); + phrase.setId(jyPhraseId); + phrase.setPhraseNormalized("look up"); + phrase.setUpdateTime(1_700_000_000L); + when(englishPhraseCoreService.getById(jyPhraseId, 0L)).thenReturn(phrase); + when(englishPhraseVariantCoreService.listByPhraseId(jyPhraseId, 0L)).thenReturn(List.of()); + when(englishPhraseCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenThrow(new RuntimeException("db down")); + + PublishResultBo result = publishService.publishPhrase(jyPhraseId, operator); + + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + assertFalse(result.isJyStatusConfirmed()); + verify(englishPhraseCoreService).registerHasPublished(jyPhraseId, 9L); + // 短语侧三项都不能漏:详情失效、索引重建、全量重分词脏标——否则学生点在旧短语上且分词永不重算。 + org.mockito.Mockito.verifyNoInteractions(rocketMqProducerService); + org.mockito.Mockito.verifyNoInteractions(englishPhraseIndexCacheService); + } + + @Test + void batchPublishMovesCursorByLastScannedIdAndReportsPartialFailure() { + EnglishWordDo first = word(); + EnglishWordDo second = word(); + second.setId(100_000_003L); + when(englishWordCoreService.listPendingPublish(0L, 100)).thenReturn(List.of(first, second)); + // 批量发布在锁内按 ID 重读当前行,桩必须按各自 JY ID 命中,否则 requireWord 返回 null 会被判为失败。 + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(first); + when(englishWordCoreService.getById(100_000_003L, 0L)).thenReturn(second); + when(englishWordTranslationCoreService.listByWordIds(anyList(), eq(0L))).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + var batch = publishService.publishPendingWords(null, 100, operator); + + assertEquals(2, batch.getScannedCount()); + assertEquals(100_000_003L, batch.getNextCursor().longValue()); + assertTrue(batch.isFinished()); + assertEquals(0, batch.getFailedCount()); + assertEquals(2, batch.getPendingCacheCount()); + } + + /** + * 第3轮问题10 回归:单条因业务校验失败时,明细里必须是可读的业务原因, + * 不能只剩一个异常类名(运营分不清该修资源、改文本还是重试锁)。 + */ + @Test + void batchPublishRecordsBusinessMessageAsFailureReason() { + EnglishWordDo pending = word(); + when(englishWordCoreService.listPendingPublish(0L, 100)).thenReturn(List.of(pending)); + // 锁内重读已被并发删除 → requireWord 抛带用户可读消息的业务异常。 + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn((EnglishWordDo) null); + + var batch = publishService.publishPendingWords(null, 100, operator); + + assertEquals(1, batch.getFailedCount()); + assertEquals(1, batch.getFailures().size()); + String reason = batch.getFailures().get(0).getReason(); + org.junit.jupiter.api.Assertions.assertNotEquals("BusinessException", reason); + org.junit.jupiter.api.Assertions.assertFalse(org.apache.commons.lang3.StringUtils.isBlank(reason), reason); + } + + @Test + void publishWordRereadsInsideLockAndAbortsWhenRowDeleted() { + // 锁外首读存在,锁内重读时已被并发删除:必须在触碰 PROD 前失败,不留孤儿词条(问题12)。 + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word(), (EnglishWordDo) null); + + assertThrows(RuntimeException.class, () -> publishService.publishWord(JY_ID, operator)); + + verify(englishDictPublishProServiceHelper, never()).publishWordInPro(any(), anyList(), anyList(), any()); + } + + @Test + void publishPendingPhrasesCountsSuccessAndBusinessFailure() { + EnglishPhraseDo ok = phrase(200L); + EnglishPhraseDo deleted = phrase(201L); + when(englishPhraseCoreService.listPendingPublish(0L, 100)).thenReturn(List.of(ok, deleted)); + when(englishPhraseCoreService.getById(200L, 0L)).thenReturn(ok); + // 201 在锁内重读时已被并发删除:requirePhrase 抛带用户可读消息的业务异常。 + when(englishPhraseCoreService.getById(201L, 0L)).thenReturn((EnglishPhraseDo) null); + when(englishPhraseVariantCoreService.listByPhraseId(any(), eq(0L))).thenReturn(List.of()); + when(englishPhraseCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), any())) + .thenReturn(true); + + var batch = publishService.publishPendingPhrases(null, 100, operator); + + assertEquals(2, batch.getScannedCount()); + assertEquals(1, batch.getSuccessCount()); + assertEquals(1, batch.getFailedCount()); + assertEquals(201L, batch.getNextCursor().longValue()); + assertTrue(batch.isFinished()); + // 失败明细要能定位到具体是哪条:identifier 携类型+ID,原因不能是空。 + assertEquals("PHRASE:201", batch.getFailures().get(0).getIdentifier()); + assertFalse(org.apache.commons.lang3.StringUtils.isBlank(batch.getFailures().get(0).getReason())); + } + + @Test + void publishPendingWordsTreatsUnconfirmedProdWriteAsFailureWithDefaultReason() { + EnglishWordDo first = word(); + EnglishWordDo second = word(); + second.setId(100_000_003L); + when(englishWordCoreService.listPendingPublish(0L, 2)).thenReturn(List.of(first, second)); + PublishResultBo unconfirmed = new PublishResultBo(); + unconfirmed.setProdPublished(false); + PublishResultBo succeeded = new PublishResultBo(); + succeeded.setProdPublished(true); + when(englishDictWriteLockService.callWithWordLock(nullable(Long.class), anyString(), any(Supplier.class))) + .thenReturn(unconfirmed, succeeded); + + var batch = publishService.publishPendingWords(null, 2, operator); + + assertEquals(1, batch.getFailedCount()); + assertEquals(1, batch.getSuccessCount()); + assertEquals("WORD:" + JY_ID, batch.getFailures().get(0).getIdentifier()); + // 未发布成功必须带原因上浮,否则任务侧只有计数没有条目。 + assertEquals("PROD 写入未确认", batch.getFailures().get(0).getReason()); + // 本批取满 limit,不能说整表已完成,否则任务会提前收工。 + assertFalse(batch.isFinished()); + } + + @Test + void batchSizeIsClampedAndCursorDefaultsToZero() { + when(englishWordCoreService.listPendingPublish(anyLong(), anyInt())).thenReturn(List.of()); + + assertTrue(publishService.publishPendingWords(null, 0, operator).isFinished()); + verify(englishWordCoreService).listPendingPublish(0L, 1); + + publishService.publishPendingWords(50L, 9999, operator); + // 上限 500:超出只会在 PROD 事务里干更久的活,对进度没有帮助。 + verify(englishWordCoreService).listPendingPublish(50L, 500); + } + + @Test + void nonBusinessExceptionIsReportedBySimpleClassName() { + EnglishPhraseDo pending = phrase(300L); + when(englishPhraseCoreService.listPendingPublish(0L, 100)).thenReturn(List.of(pending)); + when(englishPhraseCoreService.getById(300L, 0L)).thenThrow(new IllegalStateException("db down")); + + var batch = publishService.publishPendingPhrases(null, 100, operator); + + assertEquals(1, batch.getFailedCount()); + // 非业务异常没有可读消息,回退到类名,避免明细里全是空字符串。 + assertEquals("IllegalStateException", batch.getFailures().get(0).getReason()); + } + + @Test + void prodCacheInvalidationFailureDoesNotRollBackPublishResult() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(anyList(), eq(0L))).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), any())) + .thenReturn(true); + // MQ投递由outbox恢复者执行;发布服务只返回持久化后续任务状态。 + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + // PROD 已提交是既定事实:缓存失效失败只能落日志,不能把已发布的词条说成失败。 + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + assertTrue(result.isJyStatusConfirmed()); + } + + @Test + void registerHasPublishedFailureIsSwallowedWhenWritebackAlsoFails() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(anyList(), eq(0L))).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), any())) + .thenThrow(new RuntimeException("db down")); + when(englishWordCoreService.registerHasPublished(anyLong(), any())) + .thenThrow(new IllegalStateException("db down")); + + PublishResultBo result = publishService.publishWord(JY_ID, operator); + + // 两层失败都只能落日志:发布结果仍以 PROD 提交为准,运营重发一次即追平。 + assertTrue(result.isProdPublished()); + assertTrue(result.isCacheInvalidationPending()); + assertFalse(result.isJyStatusConfirmed()); + verify(englishWordCoreService).registerHasPublished(eq(JY_ID), eq(9L)); + } + + @Test + void phrasePublishLoadsMainAndVariantAudioAndLocksVariantTexts() { + long phraseId = 400L; + long variantAudioId = 900L; + EnglishPhraseDo phrase = phrase(phraseId); + EnglishPhraseVariantDo withAudio = variant(variantAudioId, "off"); + EnglishPhraseVariantDo withoutAudio = variant(null, null); + when(englishPhraseCoreService.getById(phraseId, 0L)).thenReturn(phrase); + when(englishPhraseVariantCoreService.listByPhraseId(any(), eq(0L))) + .thenReturn(List.of(withAudio, withoutAudio)); + when(englishDictAudioResourceService.listAliveByIds(any())) + .thenReturn(List.of(audio(JY_AUDIO_ID), audio(variantAudioId))); + when(englishPhraseCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), any())) + .thenReturn(true); + + publishService.publishPhrase(phraseId, operator); + + ArgumentCaptor resourceCaptor = ArgumentCaptor.forClass(Collection.class); + verify(englishDictAudioResourceService).listAliveByIds(resourceCaptor.capture()); + // 变形音频漏带会让 PROD 端变形指向不存在的资源。 + assertEquals(Set.of(JY_AUDIO_ID, variantAudioId), new HashSet<>(resourceCaptor.getValue())); + + assertEquals(Long.valueOf(phraseId), phraseLockEntityIds.get(0)); + // 变形文本为空时不能把 null 塞进锁 key(会拼出非法 key),只保留有值的文本。 + assertEquals(Set.of("off"), new HashSet<>(phraseLockTexts.get(0))); + org.mockito.Mockito.verifyNoInteractions(englishPhraseIndexCacheService); + } + + @Test + void nullIdFailsBeforeTouchingEitherDatabase() { + assertThrows(RuntimeException.class, () -> publishService.publishWord(null, operator)); + assertThrows(RuntimeException.class, () -> publishService.publishPhrase(null, operator)); + + verify(englishDictPublishProServiceHelper, never()).publishWordInPro(any(), anyList(), anyList(), any()); + verify(englishDictPublishProServiceHelper, never()).publishPhraseInPro(any(), anyList(), anyList(), any()); + } + + private EnglishPhraseDo phrase(Long id) { + EnglishPhraseDo phrase = new EnglishPhraseDo(); + phrase.setId(id); + phrase.setPhrase("take off"); + phrase.setPhraseNormalized("take off"); + phrase.setTranslation("起飞"); + phrase.setAudioResourceId(JY_AUDIO_ID); + phrase.setStatus(EnglishPublishStatusEnum.UNPUBLISHED.getCode()); + phrase.setHasPublished(0); + phrase.setUpdateTime(1_700_000_000L); + return phrase; + } + + private EnglishPhraseVariantDo variant(Long audioResourceId, String normalizedText) { + EnglishPhraseVariantDo variant = new EnglishPhraseVariantDo(); + variant.setId(1L); + variant.setPhraseId(400L); + variant.setVariantText(normalizedText); + variant.setVariantNormalized(normalizedText); + variant.setAudioResourceId(audioResourceId); + return variant; + } + + private EnglishWordDo word() { + EnglishWordDo word = new EnglishWordDo(); + word.setId(JY_ID); + word.setWord("good"); + word.setWordNormalized("good"); + word.setPhonetic("ɡʊd"); + word.setAudioResourceId(JY_AUDIO_ID); + word.setStatus(EnglishPublishStatusEnum.UNPUBLISHED.getCode()); + word.setHasPublished(0); + word.setUpdateTime(1_700_000_000L); + return word; + } + + private EnglishWordTranslationDo translation(Long id) { + EnglishWordTranslationDo item = new EnglishWordTranslationDo(); + item.setId(id); + item.setWordId(JY_ID); + item.setPos("adj."); + item.setTranslation("好的"); + item.setSortOrder(1); + return item; + } + + private LocalResourceDo audio(Long id) { + LocalResourceDo resource = new LocalResourceDo(); + resource.setId(id); + resource.setUri("https://oss/audio.mp3"); + return resource; + } + + @Test + void prodSnapshotHelperReceivesNullSafeEmptyTranslationsWhenWordHasNone() { + when(englishWordCoreService.getById(JY_ID, 0L)).thenReturn(word()); + when(englishWordTranslationCoreService.listByWordIds(List.of(JY_ID), 0L)).thenReturn(List.of()); + when(englishWordCoreService.updatePublishStatusIfUnmodified(anyLong(), any(), any(), anyLong(), + any())).thenReturn(true); + + publishService.publishWord(JY_ID, operator); + + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + verify(englishDictPublishProServiceHelper).publishWordInPro(any(), captor.capture(), anyList(), any()); + assertTrue(captor.getValue().isEmpty()); + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/dict/SegmentAutoFillBizServiceTest.java @@ -0,0 +1,459 @@ +package cn.jojo.cc.biz.service.english.dict; + +import cn.jojo.cc.adaptor.english.BaiduDictClient; +import cn.jojo.cc.adaptor.english.dto.BaiduDictResult; +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.model.bo.english.EnglishWordTranslationBo; +import cn.jojo.cc.biz.model.bo.english.SegmentAutoFillMessage; +import cn.jojo.cc.biz.model.bo.english.SegmentAutoRetryItemBo; +import cn.jojo.cc.biz.service.component.AliyunRocketMQService; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.RedisKeyEnum; +import cn.jojo.cc.common.enums.english.DictSourceEnum; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.english.EnglishWordDo; +import com.alibaba.fastjson.JSON; +import org.junit.jupiter.api.BeforeEach; +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.redisson.api.RList; +import org.redisson.api.RedissonClient; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.nullable; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 描述:自动补录服务测试(Task 21 的补录部分)。 + * + *

降级矩阵(§11.4):百度失败/空结果 → 空壳;TTS 失败 → 无音频; + * 幂等:锁内已存在已发布行 → 不重复失效缓存、不重复发布; + * 发布判定取锁内落库返回行的 status/hasPublished,不另做锁外预读;发布动作本身在文本锁之外发起 + * (锁序要求,见 publishIsRequestedOutsideTextLock); + * 补录整体失败必须落持久化重试队列(MQ 已 ACK,broker 不会重投)。

+ * + *

这里不再有"双库回刷"相关用例:库里只存划词块的文本与类型(见 {@code SegmentSnapshot}), + * ID 由下发时读时解析现算,补出词后存量题目自动可点,回刷逻辑已整块删除。

+ */ +@ExtendWith(MockitoExtension.class) +class SegmentAutoFillBizServiceTest { + + @InjectMocks + private SegmentAutoFillBizServiceImpl autoFillService; + + @Mock + private EnglishDictConfig englishDictConfig; + + @Mock + private cn.jojo.cc.core.service.english.EnglishWordCoreService englishWordCoreService; + + @Mock + private BaiduDictClient baiduDictClient; + + @Mock + private EnglishDictWriteLockService englishDictWriteLockService; + + @Mock + private EnglishDictWriteTxService englishDictWriteTxService; + + @Mock + private EnglishPublishBizService englishPublishBizService; + + @Mock + private AliyunRocketMQService rocketMqProducerService; + + @Mock + private RedissonClient redissonClient; + + @Mock + private RList retryQueue; + + private final LoginUserBo operator = new LoginUserBo(-1L, "系统"); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + lenient().when(englishDictConfig.isSegmentAutofillEnabled()).thenReturn(true); + // 执行闸门默认放行:不打桩时布尔为 false,补录会在入口被静默跳过。 + lenient().when(englishDictConfig.isSegmentExecutionEnabled()).thenReturn(true); + lenient().when(englishDictWriteLockService.callWithWordLock(nullable(Long.class), any(), any())) + .thenAnswer(invocation -> ((Supplier) invocation.getArgument(2)).get()); + // getList 的 在 when(...) 表达式位置没有目标类型可推断,会退化成 RList 而使 thenReturn 编译失败; + // 用显式类型实参修,不用 doReturn:后者绕过类型检查,将来签名变更会静默失真而不是报错。 + lenient().when(redissonClient.getList(RedisKeyEnum.ENGLISH_AUTOFILL_RETRY.buildKey())) + .thenReturn(retryQueue); + lenient().when(retryQueue.size()).thenReturn(0); + } + + @Test + void happyPathSavesAndPublishesWithSystemOperator() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq("ɡʊd"), isNull(), any(), eq(operator))).thenReturn(wordRow(100L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + verify(englishPublishBizService).publishAutoFillWord(100L, operator); + verify(rocketMqProducerService).clearRedisCommonCache(eq(EnglishWordDo.class), eq(100L), any()); + } + + @Test + void emptyResultFromBaiduFallsBackToShellRecord() { + when(baiduDictClient.query("good")).thenReturn(null); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq(null), isNull(), eq(List.of()), eq(operator))) + .thenReturn(wordRow(101L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + verify(englishPublishBizService).publishAutoFillWord(101L, operator); + } + + @Test + void baiduExceptionDegradesToShellWithoutBlockingPublish() { + when(baiduDictClient.query("good")).thenThrow(new IllegalStateException("http 522")); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq(null), isNull(), eq(List.of()), eq(operator))) + .thenReturn(wordRow(102L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + verify(englishPublishBizService).publishAutoFillWord(102L, operator); + } + + @Test + void audioLeftEmptyForBackfillTask() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq("ɡʊd"), eq(null), any(), eq(operator))).thenReturn(wordRow(103L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + // 音频不在这里生成:建行留空,由回填任务后补(避免一条消息逐词等合成) + verify(englishDictWriteTxService).saveWordForAutoFill( + eq("good"), eq("ɡʊd"), eq(null), any(), eq(operator)); + verify(englishPublishBizService).publishAutoFillWord(103L, operator); + } + + @Test + void illegalPosTranslationDroppedBeforeSave() { + stubBaidu("good", "ɡʊd", "zzz.", "好的"); + // 裸类型声明:forClass(List.class) 赋给 ArgumentCaptor> 是编译错误。 + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq("ɡʊd"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(104L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + assertTrue(captor.getValue().isEmpty()); + } + + /** + * 线上 to 原文返回 {@code "part":"inf."}:曾因词性表未登记而整块丢弃四条释义, + * 2026-10-27 按该实测证据登记进 PartOfSpeechEnum 后必须正常入库(本用例即当初的哨兵, + * 已按预设方向反转);仍无登记依据的词性(如 phr.)继续整条丢弃并 warn。 + */ + @Test + void infinitivePosIsRegisteredAndKeptAfterRealPayloadEvidence() { + stubBaidu("to", "tuː", "inf.", "表示目的或意图"); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("to"), eq("tuː"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(108L, 0, 0)); + + autoFillService.autoFill(message("to"), operator); + + List groups = captor.getValue(); + assertEquals(1, groups.size()); + assertEquals("inf.", groups.get(0).getPos()); + assertEquals("表示目的或意图", groups.get(0).getTranslation()); + } + + /** + * 无登记依据的词性(phr. 未进枚举,也不在 2026-10-27 拍板的常用全量里)仍整条丢弃: + * 取值域只认实测证据与显式拍板,不凭词典文档无限扩。 + */ + @Test + void unregisteredPosIsStillDroppedWholeWithoutGuessing() { + stubBaidu("look up", "lʊk ʌp", "phr.", "查阅"); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("look up"), eq("lʊk ʌp"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(109L, 0, 0)); + + autoFillService.autoFill(message("look up"), operator); + + assertTrue(captor.getValue().isEmpty()); + } + + /** + * 2026-10-27 线上 hello 原文返回的单条 {@code "part":"int./n."}:一条 part 只配一组 means, + * 词典没说哪条释义属于哪个词性,所以必须整串保留为一行。拆成两行各挂同一组译文, + * 等于造出「这三个意思分别属于感叹词」与「分别属于名词」两个词典里没有的断言。 + */ + @Test + void compoundPosKeptAsOneRowInsteadOfSplitting() { + stubBaidu("hello", "həˈloʊ", "int./n.", "你好\n喂"); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("hello"), eq("həˈloʊ"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(105L, 0, 0)); + + autoFillService.autoFill(message("hello"), operator); + + List groups = captor.getValue(); + assertEquals(1, groups.size()); + // 不拆、不复制:词典给的那一组译文只挂一次。 + assertEquals("你好\n喂", groups.get(0).getTranslation()); + // 复合串按枚举声明顺序重排,使 int./n. 与 n./int. 落成同一个库值。 + assertEquals("n./int.", groups.get(0).getPos()); + assertEquals(1, groups.get(0).getSortOrder()); + } + + /** + * 去重键是「规范词性 + 译文」:同一词性组合同一译文只挂一次(词典写法差异不能变成两行), + * 但不同词性组合(n. 与 n./int.)是词典给了两条不同粒度,不算重复。 + */ + @Test + void sameNormalizedPosAndTextIsDeduped() { + BaiduDictResult dict = new BaiduDictResult(); + dict.setPhonetic("həˈloʊ"); + dict.setTranslations(new ArrayList<>(List.of( + posItem("int./n.", "你好"), + posItem("n./int.", "你好"), + posItem("n.", "问候")))); + when(baiduDictClient.query("hello")).thenReturn(dict); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("hello"), eq("həˈloʊ"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(106L, 0, 0)); + + autoFillService.autoFill(message("hello"), operator); + + List groups = captor.getValue(); + // 前两项规范化后同键 → 只留一行;n.+问候 与 n./int.+你好 键不同 → 各自保留。 + assertEquals(2, groups.size()); + assertEquals("n./int.", groups.get(0).getPos()); + assertEquals("n.", groups.get(1).getPos()); + // 去重跳过的是条目而不是序号:sortOrder 必须仍然连续,否则后台会看到跳号的释义组。 + assertEquals(1, groups.get(0).getSortOrder()); + assertEquals(2, groups.get(1).getSortOrder()); + } + + /** + * 复合串里含未登记词性("prep./phr."):整条丢弃,而不是只保留 prep.。 + * 只留已知段等于把 phr. 的释义算到 prep. 头上,同样是造对应关系。 + */ + @Test + void compoundPosWithUnknownTokenDropsWholeRow() { + stubBaidu("good", "ɡʊd", "prep./phr.", "好的"); + ArgumentCaptor captor = ArgumentCaptor.forClass(List.class); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq("ɡʊd"), isNull(), captor.capture(), eq(operator))) + .thenReturn(wordRow(107L, 0, 0)); + + autoFillService.autoFill(message("good"), operator); + + assertTrue(captor.getValue().isEmpty()); + } + + /** + * 锁序回归:publishWord 内部按「ID 锁→文本锁」取锁,所以补录调它时本线程不得仍持有文本锁; + * 若发布留在锁内,补录就是「文本锁→ID 锁」的逆序,会与人工保存/发布互等 3 秒后双双失败。 + * 锁服务在本类中被 mock 成直通,嵌套关系在别处看不出来,故用深度计数把“发布时已持锁”染红。 + */ + @Test + @SuppressWarnings("unchecked") + void publishIsRequestedOutsideTextLock() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + when(englishDictWriteTxService.saveWordForAutoFill( + eq("good"), eq("ɡʊd"), isNull(), any(), eq(operator))).thenReturn(wordRow(110L, 0, 0)); + + // 必须用 doAnswer().when(mock) 形式覆盖桩:写成 when(mock.callWithWordLock(...)) 时, + // 参数求值会真的调一次 mock,命中 setUp 里的直通桩并执行它的 answer,而那一刻实参全是 + // 匹配器造出的 null,getArgument(2) 直接抛空指针(本次实测踩过)。 + AtomicInteger lockDepth = new AtomicInteger(); + doAnswer(invocation -> { + lockDepth.incrementAndGet(); + try { + return ((Supplier) invocation.getArgument(2)).get(); + } finally { + lockDepth.decrementAndGet(); + } + }).when(englishDictWriteLockService).callWithWordLock(any(), any(), any()); + AtomicInteger depthAtPublish = new AtomicInteger(-1); + doAnswer(invocation -> { + depthAtPublish.set(lockDepth.get()); + return null; + }).when(englishPublishBizService).publishAutoFillWord(anyLong(), any()); + + autoFillService.autoFill(message("good"), operator); + + verify(englishPublishBizService).publishAutoFillWord(110L, operator); + assertEquals(0, depthAtPublish.get(), "发布必须在文本锁之外发起,否则与人工发布形成锁序环"); + } + + @Test + void existingPublishedRowSkipsCacheInvalidateAndPublish() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + // 锁内幂等复用到已发布旧行:既不重复失效缓存,也不重复发布。 + when(englishDictWriteTxService.saveWordForAutoFill(any(), any(), any(), any(), any())) + .thenReturn(wordRow(105L, 1, 1)); + + autoFillService.autoFill(message("good"), operator); + + verify(englishPublishBizService, never()).publishAutoFillWord(anyLong(), any()); + verify(rocketMqProducerService, never()).clearRedisCommonCache(any(), anyLong(), any()); + } + + /** + * 第四轮复审问题12:迟到补录复用运营手建的草稿时,系统不得替他发布。 + * 草稿与“补录建一半”在状态上完全同形(待发布+从未发布),只有出身能区分。 + */ + @Test + void reusedManualDraftIsNeverPublishedByAutoFill() { + stubBaidu("hippo", "\u02c8h\u026ap\u0259\u028ap", "n.", "河马"); + EnglishWordDo manualDraft = wordRow(107L, 0, 0); + manualDraft.setSource(DictSourceEnum.MANUAL.getCode()); + when(englishDictWriteTxService.saveWordForAutoFill(any(), any(), any(), any(), any())) + .thenReturn(manualDraft); + + autoFillService.autoFill(message("hippo"), operator); + + // 不发布也不清缓存:运营那份还没审完的内容不能见学生,也不允许被系统抢着推一把。 + verify(englishPublishBizService, never()).publishAutoFillWord(anyLong(), any()); + verify(rocketMqProducerService, never()).clearRedisCommonCache(any(), anyLong(), any()); + } + + /** + * 导入清单的待发布草稿同理:发布由运营确认导入结果后自己触发,补录不得代劳。 + */ + @Test + void reusedImportDraftIsNeverPublishedByAutoFill() { + stubBaidu("run", "r\u028cn", "v.", "跑"); + EnglishWordDo importDraft = wordRow(108L, 0, 0); + importDraft.setSource(DictSourceEnum.IMPORT.getCode()); + when(englishDictWriteTxService.saveWordForAutoFill(any(), any(), any(), any(), any())) + .thenReturn(importDraft); + + autoFillService.autoFill(message("run"), operator); + + verify(englishPublishBizService, never()).publishAutoFillWord(anyLong(), any()); + } + + /** + * 问题12 的姊妹缺口:运营删掉的词不得被补录重建后发布(锁内预读已含已删除行)。 + */ + @Test + void reusedSoftDeletedRowIsNeitherPublishedNorInvalidated() { + stubBaidu("hippo", "\u02c8h\u026ap\u0259\u028ap", "n.", "河马"); + EnglishWordDo deleted = wordRow(109L, 0, 0); + deleted.setDeleteTime(123L); + when(englishDictWriteTxService.saveWordForAutoFill(any(), any(), any(), any(), any())) + .thenReturn(deleted); + + autoFillService.autoFill(message("hippo"), operator); + + verify(englishPublishBizService, never()).publishAutoFillWord(anyLong(), any()); + verify(rocketMqProducerService, never()).clearRedisCommonCache(any(), anyLong(), any()); + } + + @Test + void saveFailureEnqueuesPersistentRetry() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + when(englishDictWriteTxService.saveWordForAutoFill(any(), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("db down")); + + autoFillService.autoFill(message("good"), operator); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(String.class); + verify(retryQueue).add(itemCaptor.capture()); + SegmentAutoRetryItemBo item = JSON.parseObject(itemCaptor.getValue(), SegmentAutoRetryItemBo.class); + assertEquals("good", item.getNormalizedWord()); + assertEquals("IllegalStateException", item.getReason()); + } + + @Test + void oneWordFailureDoesNotBlockOthersInSameBatch() { + stubBaidu("good", "ɡʊd", "n.", "好的"); + when(englishDictWriteTxService.saveWordForAutoFill(eq("good"), any(), any(), any(), any())) + .thenThrow(new IllegalStateException("db down")); + when(englishDictWriteTxService.saveWordForAutoFill(eq("bad"), any(), any(), any(), any())) + .thenReturn(wordRow(106L, 0, 0)); + + autoFillService.autoFill(message("good", "bad"), operator); + + verify(englishPublishBizService).publishAutoFillWord(106L, operator); + verify(retryQueue).add(any()); + } + + @Test + void autofillDisabledSkipsEverythingExternal() { + when(englishDictConfig.isSegmentAutofillEnabled()).thenReturn(false); + + autoFillService.autoFill(message("good"), operator); + + verify(baiduDictClient, never()).query(any()); + verify(englishDictWriteTxService, never()).saveWordForAutoFill(any(), any(), any(), any(), any()); + // 不写 verifyNoInteractions(redissonClient):setUp 已对其 lenient 打桩,算一次交互会直接红。 + verify(englishPublishBizService, never()).publishAutoFillWord(anyLong(), any()); + } + + private void stubBaidu(String word, String phonetic, String pos, String translation) { + BaiduDictResult dict = new BaiduDictResult(); + dict.setText(word); + dict.setPhonetic(phonetic); + dict.setTranslations(new ArrayList<>(List.of(posItem(pos, translation)))); + lenient().when(baiduDictClient.query(word)).thenReturn(dict); + } + + private BaiduDictResult.PosTranslation posItem(String pos, String translation) { + BaiduDictResult.PosTranslation item = new BaiduDictResult.PosTranslation(); + item.setPos(pos); + item.setTranslation(translation); + return item; + } + + private SegmentAutoFillMessage message(String... words) { + SegmentAutoFillMessage message = new SegmentAutoFillMessage(); + message.setSegmentSource(DataBaseTypeEnum.TEACHING.getValue()); + message.setNormalizedWords(new LinkedHashSet<>(List.of(words))); + message.setQuestionId(1L); + return message; + } + + private EnglishWordDo wordRow(Long id, Integer status, Integer hasPublished) { + EnglishWordDo row = new EnglishWordDo(); + row.setId(id); + row.setWord("good"); + row.setWordNormalized("good"); + // 默认出身=自动补录:这些桩模拟的都是“补录自己建行”那条路径(问题12 后发布判定依赖出身)。 + row.setSource(DictSourceEnum.SEGMENT_AUTOFILL.getCode()); + row.setAutoPublishEligible(1); + row.setStatus(status); + row.setHasPublished(hasPublished); + // 补全态:让"空壳残缺"日志分支只由真正缺项的用例触发,不干扰主路径断言。 + row.setCompleteness(1); + return row; + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImplTest.java @@ -0,0 +1,385 @@ +package cn.jojo.cc.biz.service.english.dict.impl; + +import cn.jojo.cc.adaptor.english.InternalTtsClient; +import cn.jojo.cc.adaptor.english.dto.TtsRequest; +import cn.jojo.cc.adaptor.english.dto.TtsTaskStatus; +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.model.bo.english.EnglishAudioBackfillResultBo; +import cn.jojo.cc.biz.service.english.dict.EnglishPublishBizService; +import cn.jojo.cc.biz.service.english.word.EnglishAudioBizService; +import cn.jojo.cc.core.domain.english.EnglishPhraseDo; +import cn.jojo.cc.core.domain.english.EnglishPhraseVariantDo; +import cn.jojo.cc.core.domain.english.EnglishWordDo; +import cn.jojo.cc.core.service.english.EnglishPhraseCoreService; +import cn.jojo.cc.core.service.english.EnglishPhraseVariantCoreService; +import cn.jojo.cc.core.service.english.EnglishWordCoreService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.redisson.api.RAtomicLong; +import org.redisson.api.RedissonClient; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 描述:音频回填与补提交测试。 + * + *

断言语义按业务后果:完成的任务必须落音频并清在途标记;终态失败必须清标记(否则该词永远显示「生成中」); + * 超时必须先取消平台任务再清理;下载失败不能清任务号(清掉就再也不会自动补音频)。

+ */ +@ExtendWith(MockitoExtension.class) +class EnglishAudioBackfillBizServiceImplTest { + + private static final int BATCH = 100; + + private EnglishAudioBackfillBizServiceImpl service; + + @Mock + private cn.jojo.cc.biz.service.english.dict.EnglishAudioCompletionTxService completionTx; + + @Mock + private RAtomicLong cursorCounter; + + @Mock + private EnglishDictConfig englishDictConfig; + + @Mock + private InternalTtsClient internalTtsClient; + + @Mock + private EnglishAudioBizService englishAudioBizService; + + @Mock + private EnglishPublishBizService englishPublishBizService; + + @Mock + private EnglishWordCoreService englishWordCoreService; + + @Mock + private EnglishPhraseCoreService englishPhraseCoreService; + + @Mock + private EnglishPhraseVariantCoreService englishPhraseVariantCoreService; + + @Mock + private RedissonClient redissonClient; + + @Mock + private RAtomicLong failCounter; + + @BeforeEach + void setUp() { + service = new EnglishAudioBackfillBizServiceImpl(); + ReflectionTestUtils.setField(service, "englishDictConfig", englishDictConfig); + ReflectionTestUtils.setField(service, "englishAudioCompletionTxService", completionTx); + lenient().when(completionTx.complete(anyString(), anyLong(), anyLong())).thenAnswer(i -> { + String table = i.getArgument(0); Long id = i.getArgument(1); Long audio = i.getArgument(2); + return "word".equals(table) ? englishWordCoreService.markAudioCompleted(id, audio) + : "phrase".equals(table) ? englishPhraseCoreService.markAudioCompleted(id, audio) + : englishPhraseVariantCoreService.markAudioCompleted(id, audio); + }); + ReflectionTestUtils.setField(service, "internalTtsClient", internalTtsClient); + ReflectionTestUtils.setField(service, "englishAudioBizService", englishAudioBizService); + ReflectionTestUtils.setField(service, "englishPublishBizService", englishPublishBizService); + ReflectionTestUtils.setField(service, "englishWordCoreService", englishWordCoreService); + ReflectionTestUtils.setField(service, "englishPhraseCoreService", englishPhraseCoreService); + ReflectionTestUtils.setField(service, "englishPhraseVariantCoreService", englishPhraseVariantCoreService); + ReflectionTestUtils.setField(service, "redissonClient", redissonClient); + + lenient().when(englishDictConfig.getTtsAudioTaskBatchSize()).thenReturn(BATCH); + lenient().when(englishDictConfig.getTtsAudioTaskTimeoutSeconds()).thenReturn(1800); + lenient().when(englishDictConfig.getTtsAudioBackfillMaxRetry()).thenReturn(3); + lenient().when(redissonClient.getAtomicLong(anyString())).thenReturn(failCounter); + lenient().when(redissonClient.getAtomicLong(org.mockito.ArgumentMatchers.contains("cursor:"))) + .thenReturn(cursorCounter); + // 条件更新默认命中:不显式断言并发竞争的用例按「写入成功」走 + lenient().when(englishWordCoreService.markAudioCompleted(anyLong(), anyLong())).thenReturn(true); + lenient().when(englishPhraseCoreService.markAudioCompleted(anyLong(), anyLong())).thenReturn(true); + lenient().when(englishPhraseVariantCoreService.markAudioCompleted(anyLong(), anyLong())).thenReturn(true); + } + + @Test + @DisplayName("任务已完成:下载落库并清在途标记") + void completedTaskIsSavedAndCleared() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 60))); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + when(englishAudioBizService.saveAudio("task-1")).thenReturn(900L); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getScanned()); + assertEquals(1, result.getCompleted()); + verify(englishWordCoreService).markAudioCompleted(1L, 900L); + verify(englishWordCoreService, never()).markAudioTaskCleared(anyLong()); + } + + @Test + @DisplayName("下载或落库失败:保留任务号,下轮再试") + void saveFailureKeepsTaskId() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 60))); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + when(englishAudioBizService.saveAudio("task-1")).thenReturn(null); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getFailed()); + // 关键断言:不清任务号,否则这个词再也不会自动补音频 + verify(englishWordCoreService, never()).markAudioTaskCleared(anyLong()); + verify(englishWordCoreService, never()).markAudioCompleted(anyLong(), anyLong()); + } + + @Test + @DisplayName("平台终态失败:清在途标记,音频留空") + void failedTaskIsCleared() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 60))); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "failed")); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getFailed()); + verify(englishWordCoreService).markAudioTaskCleared(1L); + } + + @Test + @DisplayName("仍在生成且未超时:保留任务号,留给下一轮") + void runningTaskWithinTimeoutIsKept() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 60))); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "running")); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getRunning()); + verify(englishWordCoreService, never()).markAudioTaskCleared(anyLong()); + } + + @Test + @DisplayName("在途超时:先取消平台任务再清标记") + void timeoutTaskIsCancelledThenCleared() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 4000))); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "pending")); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getFailed()); + verify(internalTtsClient).cancelTask("task-1"); + verify(englishWordCoreService).markAudioTaskCleared(1L); + } + + @Test + @DisplayName("查状态异常:按未完成处理,不清任务号") + void queryExceptionKeepsTaskId() { + when(englishWordCoreService.listAudioInFlight(BATCH)) + .thenReturn(List.of(wordRow(1L, "task-1", nowSeconds() - 60))); + lenient().when(internalTtsClient.queryTask("task-1")).thenThrow(new IllegalStateException("platform down")); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getRunning()); + verify(englishWordCoreService, never()).markAudioTaskCleared(anyLong()); + } + + @Test + @DisplayName("补提交开关关闭:不扫待提交行") + void backfillDisabledDoesNotSubmit() { + lenient().when(englishDictConfig.isTtsAudioBackfillEnabled()).thenReturn(false); + + service.runOnce(); + + verify(englishWordCoreService, never()).listAudioPendingSubmitAfterId(anyInt(), anyLong(), anyLong()); + } + + @Test + @DisplayName("补提交成功:回写任务号") + void submitSuccessWritesTaskId() { + when(englishDictConfig.isTtsAudioBackfillEnabled()).thenReturn(true); + when(englishWordCoreService.listAudioPendingSubmitAfterId(eq(BATCH), anyLong(), anyLong())) + .thenReturn(List.of(wordRow(1L, null, 0L))); + when(internalTtsClient.submitTask(any(TtsRequest.class))).thenReturn("task-9"); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getSubmitted()); + verify(englishWordCoreService).markAudioTaskSubmitted(eq(1L), eq("task-9"), anyLong()); + } + + @Test + @DisplayName("补提交失败:计一次失败,不写任务号") + void submitFailureIncrementsCounter() { + when(englishDictConfig.isTtsAudioBackfillEnabled()).thenReturn(true); + when(englishWordCoreService.listAudioPendingSubmitAfterId(eq(BATCH), anyLong(), anyLong())) + .thenReturn(List.of(wordRow(1L, null, 0L))); + when(internalTtsClient.submitTask(any(TtsRequest.class))) + .thenThrow(new IllegalStateException("platform down")); + when(failCounter.isExists()).thenReturn(false); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getSubmitFailed()); + verify(failCounter).incrementAndGet(); + verify(englishWordCoreService, never()).markAudioTaskSubmitted(anyLong(), anyString(), anyLong()); + } + + @Test + @DisplayName("失败次数达上限:不再提交,避免一直烧配额") + void submitSkippedAfterMaxRetry() { + when(englishDictConfig.isTtsAudioBackfillEnabled()).thenReturn(true); + when(englishWordCoreService.listAudioPendingSubmitAfterId(eq(BATCH), anyLong(), anyLong())) + .thenReturn(List.of(wordRow(1L, null, 0L))); + when(failCounter.isExists()).thenReturn(true); + when(failCounter.get()).thenReturn(3L); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getSubmitFailed()); + verify(internalTtsClient, never()).submitTask(any(TtsRequest.class)); + } + + @Test + @DisplayName("变形补提交:父短语是人工建的不自动补音频") + void variantOfManualPhraseIsNotSubmitted() { + when(englishDictConfig.isTtsAudioBackfillEnabled()).thenReturn(true); + EnglishPhraseVariantDo variant = variantRow(7L, 5L, "takes off"); + when(englishPhraseVariantCoreService.listAudioPendingSubmitAfterId(eq(BATCH), anyLong(), anyLong())) + .thenReturn(List.of(variant)); + EnglishPhraseDo manualPhrase = new EnglishPhraseDo(); + manualPhrase.setId(5L); + manualPhrase.setSource(1); + when(englishPhraseCoreService.listByIds(anyCollection(), eq(0L))) + .thenReturn(List.of(manualPhrase)); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(0, result.getSubmitted()); + verify(internalTtsClient, never()).submitTask(any(TtsRequest.class)); + } + + @Test + @DisplayName("已发布过的词回填后补一次发布,学生库才能拿到音频") + void completedPublishedWordRepublishes() { + EnglishWordDo row = wordRow(1L, "task-1", nowSeconds() - 60); + row.setHasPublished(1); + when(englishWordCoreService.listAudioInFlight(BATCH)).thenReturn(List.of(row)); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + when(englishAudioBizService.saveAudio("task-1")).thenReturn(900L); + + service.runOnce(); + + verify(englishPublishBizService).publishAudioWord(eq(1L), any()); + } + + @Test + @DisplayName("没发布过的词回填后不自动发布:发布是人工闸门") + void completedUnpublishedWordIsNotPublished() { + EnglishWordDo row = wordRow(1L, "task-1", nowSeconds() - 60); + row.setHasPublished(0); + when(englishWordCoreService.listAudioInFlight(BATCH)).thenReturn(List.of(row)); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + when(englishAudioBizService.saveAudio("task-1")).thenReturn(900L); + + service.runOnce(); + + verify(englishPublishBizService, never()).publishAudioWord(anyLong(), any()); + } + + @Test + @DisplayName("变形回填后触发所属短语补发布(变形没有独立发布入口)") + void completedVariantRepublishesParentPhrase() { + EnglishPhraseVariantDo variant = variantRow(7L, 5L, "takes off"); + variant.setAudioTaskId("task-7"); + variant.setAudioTaskTime(nowSeconds() - 60); + when(englishPhraseVariantCoreService.listAudioInFlight(BATCH)).thenReturn(List.of(variant)); + lenient().when(internalTtsClient.queryTask("task-7")).thenReturn(status("task-7", "completed")); + when(englishAudioBizService.saveAudio("task-7")).thenReturn(910L); + EnglishPhraseDo publishedPhrase = new EnglishPhraseDo(); + publishedPhrase.setId(5L); + publishedPhrase.setHasPublished(1); + when(englishPhraseCoreService.listByIds(anyCollection(), eq(0L))) + .thenReturn(List.of(publishedPhrase)); + + service.runOnce(); + + verify(englishPublishBizService).publishAudioPhrase(eq(5L), any()); + } + + @Test + @DisplayName("在途期间运营已上传音频:不下载、不覆盖,只清任务号") + void manualAudioUploadedWhileInFlightIsKept() { + EnglishWordDo row = wordRow(1L, "task-1", nowSeconds() - 60); + row.setAudioResourceId(500L); + when(englishWordCoreService.listAudioInFlight(BATCH)).thenReturn(List.of(row)); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + + EnglishAudioBackfillResultBo result = service.runOnce(); + + assertEquals(1, result.getCompleted()); + // 人工优先:连下载都不做,更不能写回 + verify(englishAudioBizService, never()).saveAudio(anyString()); + verify(englishWordCoreService, never()).markAudioCompleted(anyLong(), anyLong()); + verify(englishWordCoreService).markAudioTaskCleared(1L); + } + + @Test + @DisplayName("回填写入时发现已被人工补齐(条件更新未命中):保留人工音频") + void manualAudioRacingWithBackfillIsKept() { + EnglishWordDo row = wordRow(1L, "task-1", nowSeconds() - 60); + when(englishWordCoreService.listAudioInFlight(BATCH)).thenReturn(List.of(row)); + lenient().when(internalTtsClient.queryTask("task-1")).thenReturn(status("task-1", "completed")); + when(englishAudioBizService.saveAudio("task-1")).thenReturn(900L); + when(englishWordCoreService.markAudioCompleted(1L, 900L)).thenReturn(false); + + service.runOnce(); + + // 条件更新未命中就地停止:不再二次写入,只把任务号清掉 + verify(englishWordCoreService).markAudioCompleted(1L, 900L); + verify(englishWordCoreService).markAudioTaskCleared(1L); + } + + private EnglishWordDo wordRow(Long id, String taskId, long taskTime) { + EnglishWordDo row = new EnglishWordDo(); + row.setId(id); + row.setWord("good"); + row.setAudioTaskId(taskId); + row.setAudioTaskTime(taskTime); + return row; + } + + private EnglishPhraseVariantDo variantRow(Long id, Long phraseId, String text) { + EnglishPhraseVariantDo row = new EnglishPhraseVariantDo(); + row.setId(id); + row.setPhraseId(phraseId); + row.setVariantText(text); + return row; + } + + private TtsTaskStatus status(String taskId, String status) { + TtsTaskStatus result = new TtsTaskStatus(); + result.setTaskId(taskId); + result.setStatus(status); + return result; + } + + private long nowSeconds() { + return System.currentTimeMillis() / 1000; + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizServiceTest.java @@ -0,0 +1,286 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.DataSourceEnum; +import cn.jojo.cc.common.enums.english.SegmentStatusEnum; +import cn.jojo.cc.core.domain.english.QuestionSegmentDo; +import cn.jojo.cc.core.multidatasource.MultiDataSourceContextHolder; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.HashSet; +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.anyCollection; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 描述:下发侧划词装配测试。 + * + *

锁的是装配层自己的承诺,不复用解析层的结论:① 库的选择跟着线程数据源走;② 数据源判不出来就 + * 整批不解析(宁可不点,也不给错库的 ID);③ 没有分词行的 key 不出现在结果里,绝不用空序列冒充 + * “分过词但无命中”;④ 单题脏数据只丢该题,不带崩整批;⑤ 库存里的历史 ID 不得绕过读时解析下发。

+ */ +@ExtendWith(MockitoExtension.class) +class EnglishSegmentDeliveryBizServiceTest { + + private static final String LEGACY_ROW = "{\"matches\":[{\"text\":\"good\",\"type\":\"word\",\"wordId\":999}]}"; + private static final String NORMAL_ROW = "{\"matches\":[{\"text\":\"good\",\"type\":\"word\"}]}"; + + @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)); + } + + @AfterEach + void clearThreadDataSource() { + MultiDataSourceContextHolder.clear(); + } + + @Test + void segmentDisabledReturnsEmptyWithoutTouchingDb() { + // 回滚契约:总开关关闭 = 一键止血,不查库、整批不下发,存量分词行也不透出。 + when(englishDictConfig.isSegmentEnabled()).thenReturn(false); + + Map result = + bizService.resolveByQuestionKeys(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); + // 真实表里 segment_status NOT NULL DEFAULT 0,成功行不下状态会静默走“非成功不下发”分支。 + row.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + return row; + } + + @Test + void failedRowIsNotDeliveredEvenWithLegacySnapshot() { + // 第四轮复审问题04:失败行的 segments 还是上一次成功时的旧正文序列,正文已推进时 + // 下发它会让学生点在错位置上(按顺序定位)——宁可本题不可点,也不给旧坐标。 + 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")); + + assertTrue(result.isEmpty()); + verify(segmentMatchIdResolver, never()).resolveIds(any(), any()); + } + + @Test + void emptyOrNullKeysQueryNothing() { + assertTrue(bizService.resolveByQuestionKeys(null).isEmpty()); + assertTrue(bizService.resolveByQuestionKeys(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")); + + assertEquals(1, result.size()); + verify(segmentMatchIdResolver).resolveIds(eq(DataBaseTypeEnum.PROD), any()); + } + + @Test + void teachingThreadResolvesAgainstJyDictionary() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.DEFAULT.getValue()); + when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) + .thenReturn(List.of(row("q1", NORMAL_ROW))); + + bizService.resolveByQuestionKeys(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))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Collection.class); + verify(questionSegmentCoreService).listByQuestionKeys(captor.capture()); + Collection sent = captor.getValue(); + assertEquals(1, sent.size()); + assertTrue(sent.contains("q1")); + } + + @Test + 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")); + + // “没分过词”与“分过词但一个没命中”是两回事:前者必须缺席,让调用方保持 segments=null。 + assertTrue(result.isEmpty()); + verify(segmentMatchIdResolver, never()).resolveIds(any(), any()); + } + + @Test + void emptyMatchesSequenceIsPassedThroughAsLegitimateResult() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); + when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) + .thenReturn(List.of(row("q1", "{\"matches\":[]}"))); + + Map result = bizService.resolveByQuestionKeys(Set.of("q1")); + + assertNotNull(result.get("q1")); + assertTrue(result.get("q1").getMatches().isEmpty()); + } + + @Test + 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")); + + // 一行坏数据不能让整批下发失败,也不能凭空造出一个 null key 的条目。 + assertEquals(1, result.size()); + assertNotNull(result.get("good")); + assertNull(result.get("bad")); + } + + @Test + void resolverFailureOnOneRowDoesNotRemoveOthers() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); + when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) + .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")); + + assertEquals(1, result.size()); + assertNotNull(result.get("q2")); + } + + @Test + void documentOrderAndDuplicateEntriesArePassedThroughUntouched() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); + // 同一词出现两次 = 两条完全一致的项;未命中词按出现位置输出一条占位项(非正数 ID)。 + // C 端靠逐顺序消费复现高亮位置,装配层一旦排序/去重就丢位置。 + 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")); + + List matches = result.get("q1").getMatches(); + assertEquals(3, matches.size()); + assertEquals("good", matches.get(0).getText()); + assertEquals("artemisinin", matches.get(1).getText()); + assertEquals("good", matches.get(2).getText()); + // 装配层必须把 ID 剥净(包括库存里写着的 -1):“当时查不到”不等于“现在也查不到”, + // 占位还是真 ID 只能由解析层按当前词典现算(解析行为在 SegmentMatchIdResolverTest 里钉)。 + assertNull(matches.get(0).getWordId()); + assertNull(matches.get(1).getWordId()); + assertNull(matches.get(2).getWordId()); + } + + @Test + void legacyHistoryIdNeverReachesCallerWithoutResolver() { + MultiDataSourceContextHolder.setDataSource(DataSourceEnum.PROD.getValue()); + when(questionSegmentCoreService.listByQuestionKeys(anyCollection())) + .thenReturn(List.of(row("q1", LEGACY_ROW))); + + bizService.resolveByQuestionKeys(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() { + 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()); + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentBizServiceTest.java @@ -0,0 +1,727 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.cache.english.EnglishPhraseIndexCacheService; +import cn.jojo.cc.biz.cache.english.EnglishWordCacheService; +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.model.bo.english.PhraseIndexSnapshot; +import cn.jojo.cc.biz.model.bo.english.PhraseMatch; +import cn.jojo.cc.biz.model.bo.english.SegmentSnapshot; +import cn.jojo.cc.biz.utils.MD5Util; +import cn.jojo.cc.common.dto.english.EnglishSegmentDto; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.common.enums.dict.QuestionTypeEnum; +import cn.jojo.cc.common.enums.english.SegmentContentTypeEnum; +import cn.jojo.cc.common.enums.english.SegmentStatusEnum; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.english.QuestionSegmentDo; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import com.alibaba.fastjson.JSON; +import org.junit.jupiter.api.BeforeEach; +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 java.util.Collection; +import java.util.HashMap; +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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * 纯分词引擎测试。 + */ +@ExtendWith(MockitoExtension.class) +class QuestionSegmentBizServiceTest { + + 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 static final LoginUserBo OPERATOR = new LoginUserBo(-1L, "system-test"); + + @InjectMocks + private QuestionSegmentBizServiceImpl service; + + @Mock + private SegmentTextExtractor textExtractor; + + @Mock + private EnglishPhraseIndexCacheService phraseIndexCacheService; + + @Mock + private EnglishWordCacheService wordCacheService; + + @Mock + private QuestionSegmentCoreService questionSegmentCoreService; + + /** + * 执行闸门:不显式打桩时 Mockito 给 false,全部用例会静默走进“已暂停”分支,一行都不算。 + */ + @Mock + private EnglishDictConfig englishDictConfig; + + /** + * 默认视为短语索引已就绪:不显式打桩时 Mockito 对对象返回 null, + * 所有落库用例都会走“未就绪跳过”分支而断不到东西。 + * 同理适用于问题11 后返回 boolean 的 upsert:不打桩默认 false,所有“已落库” + * 的用例会静默走进“拒绝不落库”分支而失去断言意义。 + */ + @BeforeEach + void setUpIndexReady() { + SegmentContentSnapshotService content = new SegmentContentSnapshotService(); + org.springframework.test.util.ReflectionTestUtils.setField(content, "segmentTextExtractor", textExtractor); + QuestionSegmentTxService tx = new QuestionSegmentTxService(); + org.springframework.test.util.ReflectionTestUtils.setField(tx, "segments", questionSegmentCoreService); + org.springframework.test.util.ReflectionTestUtils.setField(tx, "content", content); + org.springframework.test.util.ReflectionTestUtils.setField(service, "segmentContentSnapshotService", content); + org.springframework.test.util.ReflectionTestUtils.setField(service, "questionSegmentTxService", tx); + lenient().when(phraseIndexCacheService.freshSnapshotForWrite(any())) + .thenReturn(PhraseIndexSnapshot.empty(1L)); + lenient().when(phraseIndexCacheService.readySnapshot(any())) + .thenReturn(PhraseIndexSnapshot.empty(1L)); + lenient().when(questionSegmentCoreService.upsert(any())).thenReturn(true); + lenient().when(englishDictConfig.isSegmentExecutionEnabled()).thenReturn(true); + } + + /** + * 问题11 回归:主表被修订号门拒绝(upsert 返回 false)时,本次结果不可采信, + * 未命中集合必须清空,挂接层才不会拿旧正文的旧词去投补录。 + */ + @Test + void segmentAndSaveClearsUnmatchedWhenUpsertRejected() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + stubWordIds(Map.of("known", 1L)); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + // 库存正文 MD5 必须与本题不同:否则先命中剪枝分支(问题06 后剪枝=同修订号+同正文的幂等护栏), + // 直接回显库存而根本走不到 saveResult,本用例要验的“主表门拒绝”分支不会被触发。 + existing.setSourceTextMd5(MD5Util.creatMd5("other text")); + // 修订号与题目相等→isStale 预检不拦;模拟“匹配期间更新的同修订任务已落库”后主表门拒绝本次。 + existing.setQuestionRevision(1000L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + when(questionSegmentCoreService.upsert(any())).thenReturn(false); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + verify(questionSegmentCoreService).upsert(any()); + assertTrue(result.getUnmatchedWords().isEmpty()); + } + + @Test + void segmentMatchesLongestPhraseAndSkipsConsumedWords() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("go as well as"))); + when(textExtractor.extractPlainText("go as well as")).thenReturn("go as well as"); + // go 位于短语之前,引擎必然回查它;没进桩的词一律视为词典没有。 + stubWordIds(Map.of("go", 5L)); + when(phraseIndexCacheService.findLongestAt(any(), anyString(), anyInt())) + .thenAnswer(invocation -> { + String text = invocation.getArgument(1); + int cursor = invocation.getArgument(2); + // 短语故意不落在首个 token 起点 0:只有引擎改用 token.start() 试短语才能命中(锁住游标缺陷回归) + if (cursor == 3) { + return new PhraseMatch(10L, null, "as well as", text.length()); + } + return null; + }); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + // 短语命中不吞掉相邻独立单词,且序列保持文档序:go 先于短语出现,两条按扫描顺序输出。 + assertEquals(2, result.getSegments().getMatches().size()); + assertEquals("word", result.getSegments().getMatches().get(0).getType()); + assertEquals("go", result.getSegments().getMatches().get(0).getText()); + assertEquals("phrase", result.getSegments().getMatches().get(1).getType()); + assertEquals(10L, result.getSegments().getMatches().get(1).getPhraseId()); + // SkipsConsumedWords 的真实语义只约束短语消耗区间内的词:游标退化回逐词推进时这里会红。 + verify(wordCacheService).findIdsByNormalizeds(any(), + argThat((Collection texts) -> !texts.contains("as") && !texts.contains("well"))); + } + + @Test + void segmentRespectsWordBoundaries() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("he a cat"))); + when(textExtractor.extractPlainText("he a cat")).thenReturn("he a cat"); + stubWordIds(Map.of("he", 1L, "a", 2L, "cat", 3L)); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + assertEquals(3, result.getSegments().getMatches().size()); + assertEquals(Set.of(), result.getUnmatchedWords()); + } + + @Test + void segmentKeepsHyphenatedWordAndSplitsChineseHyphen() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)) + .thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("low-temperature low-中文"))); + when(textExtractor.extractPlainText("low-temperature low-中文")) + .thenReturn("low-temperature low-中文"); + stubWordIds(Map.of("low-temperature", 11L, "low", 12L)); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + assertEquals(2, result.getSegments().getMatches().size()); + assertEquals(Set.of(), result.getUnmatchedWords()); + } + + @Test + void segmentEmitsEveryOccurrenceAcrossProductionItemsInOrder() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("look", "up", "look"))); + when(textExtractor.extractPlainText("look")).thenReturn("look"); + when(textExtractor.extractPlainText("up")).thenReturn("up"); + stubWordIds(Map.of("look", 1L, "up", 2L)); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + // 不去重:look 跨生产项出现两次即两条内容完全一致的项,序列为 look/up/look。 + assertEquals(3, result.getSegments().getMatches().size()); + assertEquals("look", result.getSegments().getMatches().get(0).getText()); + assertEquals(1L, result.getSegments().getMatches().get(0).getWordId()); + assertEquals("up", result.getSegments().getMatches().get(1).getText()); + assertEquals("look", result.getSegments().getMatches().get(2).getText()); + assertEquals(1L, result.getSegments().getMatches().get(2).getWordId()); + } + + @Test + void segmentEmitsPlaceholderForUnmatchedWordInOrder() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known unknown"))); + when(textExtractor.extractPlainText("known unknown")).thenReturn("known unknown"); + // 只桩 known:unknown 不在桩里,批量结果里自然没有它,即“词典没有这个词”。 + stubWordIds(Map.of("known", 1L)); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + // 未命中词按出现位置输出占位项:type=word、wordId=-1;同时仍进未命中集合供落库补录。 + assertEquals(2, result.getSegments().getMatches().size()); + assertEquals("known", result.getSegments().getMatches().get(0).getText()); + assertEquals(1L, result.getSegments().getMatches().get(0).getWordId()); + assertEquals("unknown", result.getSegments().getMatches().get(1).getText()); + assertEquals("word", result.getSegments().getMatches().get(1).getType()); + assertEquals(-1L, result.getSegments().getMatches().get(1).getWordId()); + assertNull(result.getSegments().getMatches().get(1).getPhraseId()); + assertEquals(Set.of("unknown"), result.getUnmatchedWords()); + } + + @Test + void segmentRepeatsIdenticalEntryPerOccurrence() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("hello hello"))); + when(textExtractor.extractPlainText("hello hello")).thenReturn("hello hello"); + stubWordIds(Map.of("hello", 1L)); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + // 同一词出现两次:两条内容完全一致的项,@Data equals 直接断言逐条相等。 + assertEquals(2, result.getSegments().getMatches().size()); + assertEquals(result.getSegments().getMatches().get(0), result.getSegments().getMatches().get(1)); + assertEquals(1L, result.getSegments().getMatches().get(0).getWordId()); + } + + @Test + void segmentProvidedRichTextNeverTreatsTagsOrFormulaAsWords() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of(PROVIDED_RICH_TEXT))); + when(textExtractor.extractPlainText(PROVIDED_RICH_TEXT)) + .thenReturn("Betty is my good friend. She's from the USA. We are classmates. " + + "She is hard-working and clever. She always gets good grades."); + when(wordCacheService.findIdsByNormalizeds(eq(DataBaseTypeEnum.TEACHING), any())) + .thenAnswer(invocation -> { + Collection tokens = invocation.getArgument(1); + Map found = new HashMap<>(); + for (String token : tokens) { + if (!Set.of("formula", "b", "i", "color", "underline", "sup", "img").contains(token)) { + found.put(token, 1L); + } + } + return found; + }); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + assertFalse(result.getUnmatchedWords().contains("formula")); + assertFalse(result.getUnmatchedWords().contains("b")); + assertFalse(result.getUnmatchedWords().contains("i")); + assertFalse(result.getUnmatchedWords().contains("color")); + assertFalse(result.getUnmatchedWords().contains("checkmark")); + assertFalse(result.getUnmatchedWords().contains("quad")); + } + + @Test + void segmentEmitsMatchesInDocumentOrderWithoutSorting() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("zebra apple look up"))); + when(textExtractor.extractPlainText("zebra apple look up")).thenReturn("zebra apple look up"); + stubWordIds(Map.of("zebra", 1L, "apple", 2L)); + when(phraseIndexCacheService.findLongestAt(any(), anyString(), anyInt())) + .thenAnswer(invocation -> { + String text = invocation.getArgument(1); + int cursor = invocation.getArgument(2); + if (text.startsWith("look up", cursor)) { + return new PhraseMatch(20L, null, "look up", cursor + "look up".length()); + } + return null; + }); + + SegmentResult result = service.segment(question, DataBaseTypeEnum.TEACHING); + + // 文档序原样输出,不再按短语优先/字典序重排:zebra → apple → look up。 + assertEquals(3, result.getSegments().getMatches().size()); + assertEquals("zebra", result.getSegments().getMatches().get(0).getText()); + assertEquals("word", result.getSegments().getMatches().get(0).getType()); + assertEquals("apple", result.getSegments().getMatches().get(1).getText()); + assertEquals("phrase", result.getSegments().getMatches().get(2).getType()); + assertEquals("look up", result.getSegments().getMatches().get(2).getText()); + } + + @Test + void segmentPersistsResultAndUnmatchedWords() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known unknown"))); + when(textExtractor.extractPlainText("known unknown")).thenReturn("known unknown"); + // 只桩 known:unknown 不在桩里,批量结果里自然没有它。 + stubWordIds(Map.of("known", 1L)); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + ArgumentCaptor segmentCaptor = ArgumentCaptor.forClass(QuestionSegmentDo.class); + verify(questionSegmentCoreService).upsert(segmentCaptor.capture()); + assertEquals("Q_1", segmentCaptor.getValue().getQuestionKey()); + assertNotNull(segmentCaptor.getValue().getSourceTextMd5()); + assertEquals(Set.of("unknown"), result.getUnmatchedWords()); + } + + @Test + void segmentAndSaveSkipsWhenTextPhraseVersionAndRevisionUnchanged() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + stubWordIds(Map.of("known", 1L)); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(cn.jojo.cc.common.enums.english.SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(cn.jojo.cc.biz.utils.MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(), List.of(existing)); + + service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + verify(questionSegmentCoreService, org.mockito.Mockito.times(1)).upsert(any()); + } + + /** + * 索引未就绪(冷启动节流窗口内或装载失败)时不落库:否则会把必漏短语的结果固化成 NORMAL 行, + * 并把短语成分词写进未命中表污染自动补录。 + * + *

门必须在扫描开始前(问题12 的修复位置):旧写法先拿空表扫完全题、再用全局 isReady 判, + * 而全局开关会在扫描期间被其它线程翻成 true,错块正好能落库,还会把“假未命中词”交给挂接门面去投补录。

+ */ + @Test + void segmentAndSaveSkipsPersistenceWhenPhraseIndexNotReady() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + when(phraseIndexCacheService.freshSnapshotForWrite(DataBaseTypeEnum.TEACHING)).thenReturn(null); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + // 不拿空表扫:一次索引/词典查询都不应发生,也不得产出“假未命中词”去投补录事件。 + verify(phraseIndexCacheService, never()).findLongestAt(any(), anyString(), anyInt()); + verify(wordCacheService, never()).findIdsByNormalizeds(any(), any()); + assertTrue(result.getUnmatchedWords().isEmpty()); + verify(questionSegmentCoreService, never()).upsert(any()); + verify(questionSegmentCoreService, never()).markFailedByQuestionId( + any(), any(), any(), any(), any(), any()); + } + + /** + * 第四轮复审问题04:索引未就绪而正文已推进时,必须把旧正文的成功行降级为失败—— + * 否则下发仍会把旧可点序列透给学生、发布仍会把它原样搬进学生库。 + */ + @Test + void indexNotReadyDowngradesOutdatedNormalRowToFailed() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + when(phraseIndexCacheService.freshSnapshotForWrite(DataBaseTypeEnum.TEACHING)).thenReturn(null); + QuestionSegmentDo existing = new QuestionSegmentDo(); + // 库里的行一定带内容位(落库路径必写,表上 content_type 也是 NOT NULL DEFAULT 1); + // 降级就按这一行的内容位去标,夹具缺了它只会把“真实行”验成“脏行” + existing.setContentType(SegmentContentTypeEnum.MATERIAL.getCode()); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + // 库存是上一版正文算出来的:md5 与修订号都对不上现行题目,它已不可信。 + existing.setSourceTextMd5(MD5Util.creatMd5("other text")); + existing.setQuestionRevision(900L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + // 降级用的是现行正文的 md5 与修订号:语义是“这一版正文没算成功”,不是旧行坐标。 + verify(questionSegmentCoreService).markFailedByQuestionId(eq(1L), + eq(SegmentContentTypeEnum.MATERIAL.getCode()), eq("Q_1"), + eq(MD5Util.creatMd5("known")), eq(1000L), anyString()); + verify(questionSegmentCoreService, never()).upsert(any()); + } + + /** + * 全量刷遇到索引未就绪:成功行仍与现行正文一致时不得降级——它就是现行正文的结果,继续可服务。 + */ + @Test + void indexNotReadyKeepsRowThatStillMatchesCurrentText() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + when(phraseIndexCacheService.freshSnapshotForWrite(DataBaseTypeEnum.TEACHING)).thenReturn(null); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + service.refreshOne(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + verify(questionSegmentCoreService, never()).markFailedByQuestionId( + any(), any(), any(), any(), any(), any()); + } + + /** + * 抽取本身失败:依旧必须登记 FAILED,且 MD5 用约定的空串 MD5,否则重试扫描无法发现该题。 + */ + @Test + void segmentAndSaveRegistersFailureWhenExtractionThrows() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenThrow(new IllegalStateException("extract boom")); + + assertThrows(IllegalStateException.class, + () -> service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR)); + + verify(questionSegmentCoreService).markFailedByQuestionId(eq(1L), + eq(SegmentContentTypeEnum.MATERIAL.getCode()), eq("Q_1"), + eq(MD5Util.creatMd5("")), eq(1000L), eq("IllegalStateException: extract boom")); + verify(questionSegmentCoreService, never()).upsert(any()); + } + + /** + * 抽取成功但匹配阶段失败:登记使用真实正文 MD5,保留可用于对比的文本指纹。 + */ + @Test + void segmentAndSaveRegistersFailureWithTextMd5WhenMatchThrows() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + when(wordCacheService.findIdsByNormalizeds(eq(DataBaseTypeEnum.TEACHING), any())) + .thenThrow(new IllegalStateException("dict db down")); + + assertThrows(IllegalStateException.class, + () -> service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR)); + + verify(questionSegmentCoreService).markFailedByQuestionId(eq(1L), + eq(SegmentContentTypeEnum.MATERIAL.getCode()), eq("Q_1"), + eq(MD5Util.creatMd5("known")), eq(1000L), eq("IllegalStateException: dict db down")); + } + + /** + * 新增 content_type 后的核心回归:失败行必须按本次抽出的内容位登记, + * 不能被库里上一版题型的旧行带偏。 + */ + @Test + void segmentAndSaveRegistersFailureOnIncomingContentTypeNotStoredOne() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)) + .thenReturn(Map.of(SegmentContentTypeEnum.ANSWER_AREA.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + when(wordCacheService.findIdsByNormalizeds(eq(DataBaseTypeEnum.TEACHING), any())) + .thenThrow(new IllegalStateException("dict db down")); + // 库里还是上一版题型留下的 MATERIAL 行,本次实际抽取的是 ANSWER_AREA。 + QuestionSegmentDo storedMaterial = new QuestionSegmentDo(); + storedMaterial.setContentType(SegmentContentTypeEnum.MATERIAL.getCode()); + storedMaterial.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + storedMaterial.setSourceTextMd5(MD5Util.creatMd5("old text")); + storedMaterial.setQuestionRevision(900L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(storedMaterial)); + + assertThrows(IllegalStateException.class, + () -> service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR)); + + verify(questionSegmentCoreService).markFailedByQuestionId(eq(1L), + eq(SegmentContentTypeEnum.ANSWER_AREA.getCode()), eq("Q_1"), + eq(MD5Util.creatMd5("known")), eq(1000L), eq("IllegalStateException: dict db down")); + verify(questionSegmentCoreService, never()).markFailedByQuestionId( + any(), eq(SegmentContentTypeEnum.MATERIAL.getCode()), any(), any(), any(), any()); + } + + /** + * 抽取阶段就失败时拿不到内容位,按题型兜底;兜底必须与抽取器共用同一份题型映射。 + */ + @Test + void extractionFailureFallsBackToContentTypeOfQuestionType() { + QuestionDo question = question(1L); + question.setQuestionType(QuestionTypeEnum.GENERAL_FILL.getCode()); + when(textExtractor.extractRichTextsByContentType(question)) + .thenThrow(new IllegalStateException("extract boom")); + + assertThrows(IllegalStateException.class, + () -> service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR)); + + verify(questionSegmentCoreService).markFailedByQuestionId(eq(1L), + eq(SegmentContentTypeEnum.ANSWER_AREA.getCode()), eq("Q_1"), + eq(MD5Util.creatMd5("")), eq(1000L), eq("IllegalStateException: extract boom")); + } + + /** + * 问题06 后剪枝分支不再重算:即便词典会报错也不被触碰,直接回显库存、不降级、不落库。 + * (旧行为“剪枝里 match() 抛异常但不降级好行”已不存在,故改为断言“不查词典不重算”)。 + * + *

2026-10-27 形态变更后本例另担一条契约:库存里那个 wordId=7 属历史值,回显时不得被采信, + * ID 只能由读时解析填入(与 {@code pruningReadsLegacySnapshotThatStillCarriesIds} 分工不同: + * 那个证旧 JSON 能解析不炸,这个证不查词典且不采信旧 ID)。

+ */ + @Test + void segmentAndSaveDoesNotConsultDictionaryWhenPruned() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + existing.setSegments(JSON.toJSONString(EnglishSegmentDto.builder() + .matches(List.of(EnglishSegmentDto.SegmentMatch.builder() + .type("word").text("known").wordId(7L).build())) + .build())); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + // 词典桩成“一调就抛”;用 lenient 避免因剪枝路径不调用它而触发 UnnecessaryStubbing。 + lenient().when(wordCacheService.findIdsByNormalizeds(eq(DataBaseTypeEnum.TEACHING), any())) + .thenThrow(new IllegalStateException("dict db down")); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + // 回显的是库存序列本身(不重算),text/type 保留;但 ID 一律不采信: + // 库里那个 wordId=7 是形态切换前的历史值,读时解析之前不得下发。 + EnglishSegmentDto.SegmentMatch echoed = result.getSegments().getMatches().get(0); + assertEquals("known", echoed.getText()); + assertEquals("word", echoed.getType()); + assertNull(echoed.getWordId()); + assertNull(echoed.getPhraseId()); + // 剪枝不查词典、不装载索引、不落库、不降级。 + verify(wordCacheService, never()).findIdsByNormalizeds(any(), any()); + verify(phraseIndexCacheService, never()).readySnapshot(any()); + verify(questionSegmentCoreService, never()).upsert(any()); + verify(questionSegmentCoreService, never()).markFailedByQuestionId( + any(), any(), any(), any(), any(), any()); + } + + /** + * 失败登记自身报错不能吞掉原始异常,否则调用方看到的是数据库错误而不是真正的失败原因。 + */ + @Test + void segmentAndSaveKeepsOriginalExceptionWhenFailureRegistrationThrows() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenThrow(new IllegalStateException("extract boom")); + doThrow(new RuntimeException("segment table unavailable")).when(questionSegmentCoreService) + .markFailedByQuestionId(any(), any(), any(), any(), any(), any()); + + IllegalStateException error = assertThrows(IllegalStateException.class, + () -> service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR)); + + assertEquals("extract boom", error.getMessage()); + } + + @Test + void refreshOneBypassesPruneAndPersistsAgain() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + stubWordIds(Map.of("known", 1L)); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + service.refreshOne(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + verify(questionSegmentCoreService).upsert(any()); + } + + /** + * 修订号落后的旧结果:主表与未命中关系都不写,也不登记失败。 + */ + @Test + void segmentAndSaveSkipsBothWritesWhenIncomingRevisionIsStale() { + QuestionDo question = question(1L); + question.setUpdateTime(900L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + stubWordIds(Map.of("known", 1L)); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("other text")); + existing.setQuestionRevision(1000L); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + verify(questionSegmentCoreService, never()).upsert(any()); + } + + /** + * 问题06 回归:剪枝命中时直接回显库存行,不重算、不落库。 + * 锁三件事:返回值=DB 库存 segments;不再查当前词典(findIdsByNormalizeds);不写主表。 + */ + @Test + void segmentAndSaveReturnsStoredSegmentsWithoutRecomputeOnPruning() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + // 库存行:正文/修订与题目一致,NORMAL;库里只存文本与类型(ID 由下发时读时解析)。 + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + existing.setSegments(JSON.toJSONString(snapshot(snapshotItem("word", "known")))); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + // 回显的必须是库存序列本身(不重算),所以 ID 为空:未过读时解析前不得直接下发。 + assertEquals(1, result.getSegments().getMatches().size()); + assertEquals("word", result.getSegments().getMatches().get(0).getType()); + assertEquals("known", result.getSegments().getMatches().get(0).getText()); + assertNull(result.getSegments().getMatches().get(0).getWordId()); + // 剪枝路径不回报未命中集合(本次无变化可回报)。 + assertTrue(result.getUnmatchedWords().isEmpty()); + // 未重算:不得查当前词典;未落库:不得写主表。 + verify(wordCacheService, never()).findIdsByNormalizeds(any(), any()); + verify(questionSegmentCoreService, never()).upsert(any()); + } + + /** + * 剪枝只看正文指纹:题目行时间戳被推进(改标题、重发布)但正文一字未动时,仍应命中剪枝。 + * + *

这是本次改造相对旧实现的唯一行为差异:以前修订号不同就会整题重算,而发布本身必然推进 + * 题目时间戳,等于每次重发布都白算一遍。

+ */ + @Test + void pruningIgnoresQuestionUpdateTimeWhenTextUnchanged() { + QuestionDo question = question(1L); + // 题目行刚被保存/发布推过时间戳,但正文没变。 + question.setUpdateTime(1100L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + existing.setSegments(JSON.toJSONString(snapshot(snapshotItem("word", "known")))); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + assertEquals(1, result.getSegments().getMatches().size()); + // 未重算、未落库:时间戳变化不是重算理由。 + verify(questionSegmentCoreService, never()).upsert(any()); + verify(wordCacheService, never()).findIdsByNormalizeds(any(), any()); + } + + @Test + void pruningReadsLegacySnapshotThatStillCarriesIds() { + QuestionDo question = question(1L); + when(textExtractor.extractRichTextsByContentType(question)).thenReturn(Map.of(SegmentContentTypeEnum.MATERIAL.getCode(), List.of("known"))); + when(textExtractor.extractPlainText("known")).thenReturn("known"); + // 形态切换前的存量行:JSON 里还带着当时算出的 wordId。必须能读且不报错,多余字段被丢弃。 + EnglishSegmentDto legacy = EnglishSegmentDto.builder() + .matches(List.of(EnglishSegmentDto.SegmentMatch.builder() + .type("word").text("known").wordId(99L).build())) + .build(); + QuestionSegmentDo existing = new QuestionSegmentDo(); + existing.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode()); + existing.setSourceTextMd5(MD5Util.creatMd5("known")); + existing.setQuestionRevision(1000L); + existing.setSegments(JSON.toJSONString(legacy)); + when(questionSegmentCoreService.listByQuestionId(1L)).thenReturn(List.of(existing)); + + SegmentResult result = service.segmentAndSave(question, DataBaseTypeEnum.TEACHING, OPERATOR); + + // 历史 ID 一律不采信(由读时解析重算),序列本身仍原样回显。 + assertEquals(1, result.getSegments().getMatches().size()); + assertEquals("known", result.getSegments().getMatches().get(0).getText()); + assertNull(result.getSegments().getMatches().get(0).getWordId()); + } + + private SegmentSnapshot snapshot(SegmentSnapshot.Item... items) { + SegmentSnapshot snapshot = new SegmentSnapshot(); + snapshot.setMatches(List.of(items)); + return snapshot; + } + + private SegmentSnapshot.Item snapshotItem(String type, String text) { + SegmentSnapshot.Item item = new SegmentSnapshot.Item(); + item.setType(type); + item.setText(text); + return item; + } + + /** + * 批量查词桩:按入参里实际出现的词返回对应 ID,未在 map 里的词视为“词典没有”。 + * + *

引擎已改成“整题去重后一次批量查”,所以一题只会发生一次调用,入参是本题全部单词; + * 逐词打桩不再适用。

+ */ + private void stubWordIds(Map wordIdByText) { + when(wordCacheService.findIdsByNormalizeds(eq(DataBaseTypeEnum.TEACHING), any())) + .thenAnswer(invocation -> { + Collection requested = invocation.getArgument(1); + Map found = new HashMap<>(); + for (String text : requested) { + Long wordId = wordIdByText.get(text); + if (wordId != null) { + found.put(text, wordId); + } + } + return found; + }); + } + + private QuestionDo question(Long id) { + QuestionDo question = new QuestionDo(); + question.setId(id); + question.setQuestionKey("Q_" + id); + question.setUpdateTime(1000L); + lenient().when(questionSegmentCoreService.lockQuestion(id)).thenReturn(question); + return question; + } +} --- a/application/src/test/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentRevisionRegressionTest.java +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentRevisionRegressionTest.java @@ -14,10 +14,16 @@ var core = mock(QuestionSegmentCoreService.class); var latest = new QuestionDo(); latest.setId(1L); latest.setUpdateTime(2L); var stale = new QuestionDo(); stale.setId(1L); stale.setUpdateTime(1L); when(core.lockQuestion(1L)).thenReturn(latest); ReflectionTestUtils.setField(service, "questionSegmentCoreService", core); + var config = mock(cn.jojo.cc.biz.common.config.EnglishDictConfig.class); + when(config.isSegmentExecutionEnabled()).thenReturn(true); + ReflectionTestUtils.setField(service, "englishDictConfig", config); + var tx = new QuestionSegmentTxService(); + ReflectionTestUtils.setField(tx, "segments", core); + ReflectionTestUtils.setField(service, "questionSegmentTxService", tx); service.refreshOne(stale, DataBaseTypeEnum.TEACHING, null); verify(core, never()).upsert(any()); verify(core, never()).softDeleteByDo(any()); verify(core, never()).markFailedByQuestionId(any(), any(), any(), any(), any(), any()); } --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentSaveHookTest.java @@ -0,0 +1,152 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.biz.model.bo.english.EnglishSegmentSyncComputeMessage; +import cn.jojo.cc.biz.model.event.english.QuestionSegmentSaveRequestedEvent; +import cn.jojo.cc.biz.service.component.AliyunRocketMQService; +import cn.jojo.cc.common.enums.CcWorkerClusterTagEnum; +import cn.jojo.cc.core.bo.LoginUserBo; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import com.alibaba.fastjson.JSON; +import org.junit.jupiter.api.BeforeEach; +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.context.ApplicationEventPublisher; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +/** + * 描述:保存挂接门面测试(Task 21)。 + * + *

钉三件事:总开关闸门、hookSave 在题目事务内只发事件、提交后监听只投消息不跑分词。 + * 分词本身的执行与补录投递由 {@code SegmentSyncComputeConsumerListenerTest} 覆盖。

+ */ +@ExtendWith(MockitoExtension.class) +class QuestionSegmentSaveHookTest { + + @InjectMocks + private QuestionSegmentSaveHookService hookService; + + @Mock + private QuestionSegmentStateTxService questionSegmentStateTxService; + + @Mock + private EnglishDictConfig englishDictConfig; + + @Mock + private AliyunRocketMQService rocketMqProducerService; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + private final LoginUserBo operator = new LoginUserBo(9L, "tester"); + + @BeforeEach + void enableSegment() { + // lenient:开关关闭/null 用例不消费该桩,strict 下会报 UnnecessaryStubbing。 + lenient().when(englishDictConfig.isSegmentEnabled()).thenReturn(true); + } + + @Test + void hookSavePublishesRequestedEventWithoutTouchingEngine() { + QuestionDo question = question(1L); + + hookService.hookSave(question, operator); + + ArgumentCaptor captor = + ArgumentCaptor.forClass(QuestionSegmentSaveRequestedEvent.class); + verify(applicationEventPublisher).publishEvent(captor.capture()); + assertSame(question, captor.getValue().getQuestion()); + assertSame(operator, captor.getValue().getOperator()); + // 事务内一条消息都不发:投递点必须落在提交之后,否则回滚会留下幽灵任务。 + verifyNoInteractions(rocketMqProducerService, questionSegmentStateTxService); + } + + @Test + void segmentDisabledSkipsEverything() { + when(englishDictConfig.isSegmentEnabled()).thenReturn(false); + + hookService.hookSave(question(2L), operator); + + verifyNoInteractions(rocketMqProducerService, applicationEventPublisher); + } + + @Test + void nullQuestionOrIdSkipsWithoutEvent() { + hookService.hookSave(null, operator); + hookService.hookSave(new QuestionDo(), operator); + + verifyNoInteractions(rocketMqProducerService, applicationEventPublisher); + } + + @Test + void listenerPublishesSyncComputeMessageAfterCommit() { + QuestionDo question = question(3L); + + hookService.onSaveRequested(new QuestionSegmentSaveRequestedEvent(this, question, operator)); + + ArgumentCaptor bodyCaptor = ArgumentCaptor.forClass(String.class); + verify(rocketMqProducerService).sendNAsync( + org.mockito.ArgumentMatchers.eq(CcWorkerClusterTagEnum.ENGLISH_SEGMENT_SYNC_COMPUTE_EVENT.getValue()), + bodyCaptor.capture(), + org.mockito.ArgumentMatchers.eq("englishSegmentSyncCompute-3")); + EnglishSegmentSyncComputeMessage message = + JSON.parseObject(bodyCaptor.getValue(), EnglishSegmentSyncComputeMessage.class); + assertEquals(3L, message.getQuestionId()); + assertEquals(9L, message.getOperatorId()); + // 提交后只投消息,不在保存线程里跑分词。 + verifyNoInteractions(applicationEventPublisher); + } + + @Test + void listenerSwallowsSendFailure() { + QuestionDo question = question(4L); + doThrow(new IllegalStateException("mq down")) + .when(rocketMqProducerService).sendNAsync(anyString(), anyString(), anyString()); + + // 关键断言:不抛出——投递失败只丢本次分词,不许回流已保存的题目链路。 + hookService.onSaveRequested(new QuestionSegmentSaveRequestedEvent(this, question, operator)); + + verifyNoInteractions(applicationEventPublisher); + } + + @Test + void listenerIgnoresNullEventAndNullQuestion() { + hookService.onSaveRequested(null); + hookService.onSaveRequested(new QuestionSegmentSaveRequestedEvent(this, null, operator)); + hookService.onSaveRequested( + new QuestionSegmentSaveRequestedEvent(this, new QuestionDo(), operator)); + + verify(rocketMqProducerService, never()).sendNAsync(anyString(), anyString(), anyString()); + verifyNoInteractions(applicationEventPublisher); + } + + @Test + void postCommitDerivedFailureStillSendsComputeMessage() { + org.mockito.Mockito.doThrow(new IllegalStateException("segment table missing")) + .when(questionSegmentStateTxService).invalidateAfterSave(3L); + hookService.onSaveRequested(new QuestionSegmentSaveRequestedEvent(this, question(3L), operator)); + verify(rocketMqProducerService).sendNAsync(anyString(), anyString(), anyString()); + } + + private QuestionDo question(Long id) { + QuestionDo question = new QuestionDo(); + question.setId(id); + question.setQuestionKey("Q_" + id); + question.setUpdateTime(1_700_000_000L); + return question; + } +} --- /dev/null +++ b/application/src/test/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTransactionRegressionTest.java @@ -0,0 +1,56 @@ +package cn.jojo.cc.biz.service.english.segment; + +import cn.jojo.cc.biz.common.config.EnglishDictConfig; +import cn.jojo.cc.common.enums.DataBaseTypeEnum; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import cn.jojo.cc.core.service.english.QuestionSegmentCoreService; +import org.junit.jupiter.api.Test; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.TransactionStatus; +import org.springframework.transaction.support.SimpleTransactionStatus; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 使用真实Spring事务拦截器验证边界;内存manager不代表MySQL锁行为。 */ +class QuestionSegmentTransactionRegressionTest { + @Test void failureRecordStartsAfterComputeProxyHasRolledBackAndReleasedItsTransaction() { + var events = new ArrayList(); var persisted = new AtomicInteger(); + var core = mock(QuestionSegmentCoreService.class); + var question = new QuestionDo(); question.setId(1L); question.setUpdateTime(1000L); + when(core.lockQuestion(1L)).thenAnswer(i -> { events.add("lock"); return question; }); + doAnswer(i -> { events.add("failed"); persisted.incrementAndGet(); return null; }) + .when(core).markFailedByQuestionId(any(), any(), any(), any(), any(), any()); + var extractor = mock(SegmentTextExtractor.class); + when(extractor.extractRichTextsByContentType(any())).thenThrow(new IllegalStateException("bad body")); + var content = new SegmentContentSnapshotService(); + ReflectionTestUtils.setField(content, "segmentTextExtractor", extractor); + var target = new QuestionSegmentTxService(); + ReflectionTestUtils.setField(target, "segments", core); + ReflectionTestUtils.setField(target, "content", content); + PlatformTransactionManager manager = new PlatformTransactionManager() { + public TransactionStatus getTransaction(TransactionDefinition def) { events.add("begin"); return new SimpleTransactionStatus(true); } + public void commit(TransactionStatus status) { events.add("commit"); } + public void rollback(TransactionStatus status) { events.add("rollback"); persisted.set(0); } + }; + var factory = new ProxyFactory(target); factory.setProxyTargetClass(true); + factory.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + var service = new QuestionSegmentBizServiceImpl(); + var config = mock(EnglishDictConfig.class); when(config.isSegmentExecutionEnabled()).thenReturn(true); + ReflectionTestUtils.setField(service, "englishDictConfig", config); + ReflectionTestUtils.setField(service, "questionSegmentCoreService", core); + ReflectionTestUtils.setField(service, "segmentContentSnapshotService", content); + ReflectionTestUtils.setField(service, "questionSegmentTxService", factory.getProxy()); + assertThrows(IllegalStateException.class, () -> service.refreshOne(question, DataBaseTypeEnum.TEACHING, null)); + assertEquals(1, persisted.get()); + assertEquals(List.of("begin", "lock", "rollback", "begin", "lock", "failed", "commit"), events); + } +} --- /dev/null +++ b/database/597084-autofill-eligibility.sql @@ -0,0 +1,4 @@ +-- Execute in BOTH JY and PROD before application deployment. +-- Existing rows intentionally remain 0: historical second timestamps cannot prove no manual edit. +ALTER TABLE cc_english_word ADD COLUMN auto_publish_eligible TINYINT NOT NULL DEFAULT 0 + COMMENT '1=new untouched autofill draft, manual save clears to 0'; --- a/infrastructure/repository/src/main/java/cn/jojo/cc/core/dao/english/QuestionSegmentDao.java +++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/dao/english/QuestionSegmentDao.java @@ -14,11 +14,11 @@ * 题目分词结果数据访问。 */ public interface QuestionSegmentDao extends SynchBaseMapper { /** 题级 DB 锁串行化跨内容位写入/删除,并与正文更新互斥。 */ - @Select("SELECT * FROM cc_new_question WHERE id = #{questionId} AND delete_time = 0 FOR UPDATE") + @Select("SELECT * FROM cc_new_question WHERE id = #{questionId} AND del_flag = 0 FOR UPDATE") cn.jojo.cc.core.domain.question.library.QuestionDo lockQuestion(@Param("questionId") Long questionId); @Insert("INSERT INTO cc_question_segment (question_id, question_key, content_type, " + "source_text_md5, question_revision, segments, segment_status, retry_count, last_error, " + "creator_id, create_time, operator_id, update_time, delete_time) " --- /dev/null +++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/domain/english/EnglishWordDo.java @@ -0,0 +1,79 @@ +package cn.jojo.cc.core.domain.english; + +import cn.jojo.cc.core.domain.DomainBaseV3Do; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +/** + * 英文单词主表。 + * + *

音频外键采用显式映射,不标注 {@code @TableRelation},由发布服务统一转换 PROD 资源 ID。

+ */ +@Data +@NoArgsConstructor +@EqualsAndHashCode(callSuper = true) +@TableName("cc_english_word") +@Schema(title = "英文单词主表", description = "english word数据对象") +public class EnglishWordDo extends DomainBaseV3Do { + + private static final long serialVersionUID = 1L; + + @TableField("word") + @Schema(title = "单词原始文本") + private String word; + + @TableField("word_normalized") + @Schema(title = "归一化文本") + private String wordNormalized; + + @TableField("phonetic") + @Schema(title = "音标") + private String phonetic; + + @TableField("audio_resource_id") + @Schema(title = "音频资源 ID") + private Long audioResourceId; + + @TableField("audio_task_id") + @Schema(title = "平台音频任务号,非空=音频在生成中") + private String audioTaskId; + + @TableField("audio_task_time") + @Schema(title = "任务提交时间(秒),0=无在途任务") + private Long audioTaskTime; + + @TableField("completeness") + @Schema(title = "完整性") + private Integer completeness; + + @TableField("status") + @Schema(title = "发布状态") + private Integer status; + + @TableField("has_published") + @Schema(title = "是否曾发布") + private Integer hasPublished; + + @TableField("source") + @Schema(title = "数据来源") + private Integer source; + + /** + * 列定义 NOT NULL DEFAULT 0(0=无音频),读侧归一为 null,保持下游「null=无音频」判定语义不变。 + * + *

写侧口径(产品规则:音频只允许替换、不允许删除):编辑时清空音频地址,地址解析出的资源 ID + * 为 null,通用更新按 NOT_NULL 策略跳过该列 → 库内旧值保留,这是刻意行为而非缺陷。将来若要支持 + * 删除音频,必须显式写 0(列是 NOT NULL,写 null 会被数据库拒绝)并同步发布链路;不得改成「总是更新」, + * 那会让改名等无关编辑把音频冲掉。

+ */ + public Long getAudioResourceId() { + return audioResourceId != null && audioResourceId > 0 ? audioResourceId : null; + } + /** 仅新建自动补录可置1;任何人工保存清零,不依赖审计时间判断。 */ + @com.baomidou.mybatisplus.annotation.TableField("auto_publish_eligible") + private Integer autoPublishEligible; +} --- /dev/null +++ b/infrastructure/repository/src/test/java/cn/jojo/cc/core/service/english/QuestionSegmentLegacySchemaRegressionTest.java @@ -0,0 +1,17 @@ +package cn.jojo.cc.core.service.english; + +import cn.jojo.cc.core.dao.english.QuestionSegmentDao; +import cn.jojo.cc.core.domain.question.library.QuestionDo; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class QuestionSegmentLegacySchemaRegressionTest { + @Test void questionLockUsesTheLegacyQuestionDeletionColumn() throws Exception { + assertNotNull(QuestionDo.class.getSuperclass().getDeclaredField("delFlag")); + String sql = QuestionSegmentDao.class.getMethod("lockQuestion", Long.class).getAnnotation(Select.class).value()[0]; + assertTrue(sql.contains("del_flag = 0")); + assertFalse(sql.contains("delete_time")); + assertTrue(sql.endsWith("FOR UPDATE")); + } +}