--- 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