--- 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
@@ -144,33 +144,36 @@
String raw = StringUtils.trimToNull(jobParam);
if (raw == null) {
return new Param(true, true, 0);
}
if (StringUtils.isNumeric(raw)) {
- return new Param(true, true, Integer.parseInt(raw));
+ return new Param(true, true, positiveLimit(raw));
}
String type = TYPE_ALL;
int limit = 0;
- for (String segment : raw.split(",")) {
+ boolean parsedAny = false;
+ for (String segment : raw.split(",", -1)) {
String pair = StringUtils.trimToEmpty(segment);
if (pair.isEmpty()) {
continue;
}
+ parsedAny = true;
String[] kv = pair.split("=", 2);
String key = StringUtils.trimToEmpty(kv[0]);
String value = kv.length > 1 ? StringUtils.trimToEmpty(kv[1]) : "";
if ("limit".equals(key)) {
if (!StringUtils.isNumeric(value)) {
throw new IllegalArgumentException("limit 必须是正整数:" + pair);
}
- limit = Integer.parseInt(value);
+ limit = positiveLimit(value);
} else if ("type".equals(key)) {
type = value;
} else {
throw new IllegalArgumentException("无法识别的参数:" + pair);
}
}
+ if (!parsedAny) { throw new IllegalArgumentException("参数不包含有效键值"); }
if (TYPE_WORDS.equals(type)) {
return new Param(true, false, limit);
}
if (TYPE_PHRASES.equals(type)) {
return new Param(false, true, limit);
@@ -178,7 +181,14 @@
if (!TYPE_ALL.equals(type)) {
throw new IllegalArgumentException("type 只支持 words / phrases / all:" + type);
}
return new Param(true, true, limit);
}
+ private static int positiveLimit(String value) {
+ if (!value.matches("[1-9][0-9]*")) {
+ throw new IllegalArgumentException("limit 必须是严格正整数");
+ }
+ return Integer.parseInt(value);
+ }
+
}
}
--- /dev/null
+++ b/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishDictOutboxJobHandler.java
@@ -0,0 +1,21 @@
+package cn.jojo.cc.worker.task;
+
+import cn.jojo.cc.biz.service.english.dict.EnglishDictOutboxBizService;
+import com.xxl.job.core.biz.model.ReturnT;
+import com.xxl.job.core.handler.annotation.XxlJob;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Component;
+
+/** 部署后必须在 XXL-Job 配置 englishDictOutboxReplay,每分钟执行。 */
+@Component
+public class EnglishDictOutboxJobHandler {
+ @Resource
+ private EnglishDictOutboxBizService service;
+
+ @XxlJob("englishDictOutboxReplay")
+ public ReturnT
必须在数据提交之后调用:版本号先于数据可见地自增,会让其它实例重装到旧数据 * 却记下新版本号,从而永久漏掉这次变更(直到下一次变更)。现有三个调用点 * (短语编辑、发布、导入完成)都在事务提交后,新增调用方须守住这一点。
*/ + /** outbox 恢复入口:版本写入失败必须保留待办,不能吞掉。 */ + public void rebuildNowReliable(DataBaseTypeEnum source, String reason) { + if (!isSourceValid(source) || redissonHelper == null) { + throw new IllegalStateException("phrase index version service unavailable"); + } + Optional教研库到正式库的写入只允许经过本服务,Controller 不得直接调用发布 helper; * 自动补录(Task 21)、批量发布(Task 17/18)与全量刷新(Task 22)都复用这四个方法。
*/ public interface EnglishPublishBizService { + + /** 系统音频补发布:实体锁内重新确认无人工草稿。 */ + PublishResultBo publishAudioWord(Long wordId, LoginUserBo operator); + PublishResultBo publishAudioPhrase(Long phraseId, LoginUserBo operator); + /** 自动补录恢复:实体锁内确认仍是未编辑的补录草稿。 */ + PublishResultBo publishAutoFillWord(Long wordId, LoginUserBo operator); /** * 发布单个单词到正式库,并回刷引用它的待修复题目。 * * @param wordId 教研库单词 ID --- 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 @@ -102,10 +102,48 @@ // 锁内重读同 publishWord(问题12);主表与变体都重新取,避免用锁外旧快照写 PROD。 () -> doPublishPhrase(requirePhrase(phraseId), englishPhraseVariantCoreService.listByPhraseId( phraseId, 0L), operator)); } + @Override + public PublishResultBo publishAudioWord(Long wordId, LoginUserBo operator) { + EnglishWordDo snapshot = requireWord(wordId); + return englishDictWriteLockService.callWithWordLock(wordId, snapshot.getWordNormalized(), () -> { + EnglishWordDo current = requireWord(wordId); + return EnglishPublishStatusEnum.PUBLISHED.getCode().equals(current.getStatus()) + && Integer.valueOf(1).equals(current.getHasPublished()) + ? doPublishWord(current, operator) : new PublishResultBo(); + }); + } + + @Override + public PublishResultBo publishAudioPhrase(Long phraseId, LoginUserBo operator) { + EnglishPhraseDo snapshot = requirePhrase(phraseId); + return englishDictWriteLockService.callWithPhraseLocks(phraseId, snapshot.getPhraseNormalized(), + variantTextsOf(englishPhraseVariantCoreService.listByPhraseId(phraseId, 0L)), () -> { + EnglishPhraseDo current = requirePhrase(phraseId); + return EnglishPublishStatusEnum.PUBLISHED.getCode().equals(current.getStatus()) + && Integer.valueOf(1).equals(current.getHasPublished()) + ? doPublishPhrase(current, englishPhraseVariantCoreService.listByPhraseId(phraseId, 0L), operator) + : new PublishResultBo(); + }); + } + + @Override + public PublishResultBo publishAutoFillWord(Long wordId, LoginUserBo operator) { + EnglishWordDo snapshot = requireWord(wordId); + return englishDictWriteLockService.callWithWordLock(wordId, snapshot.getWordNormalized(), () -> { + 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()); + return eligible ? doPublishWord(current, operator) : new PublishResultBo(); + }); + } + /** * 批次失败条目明细累加(问题08):identifier 携实体类型+ID,rowNo 不适用批量发布场景留空。 */ private static void addBatchFailure(List表上的唯一键是 (question_id, content_type):PRD 规定一道题只属于一个内容位
--- /dev/null
+++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/domain/english/EnglishDictOutboxDo.java
@@ -0,0 +1,22 @@
+package cn.jojo.cc.core.domain.english;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+/** 单库事务内的缓存/音频发布待办;UUID 不参与教研/正式业务 ID 映射。 */
+@Data
+@TableName("cc_english_dict_outbox")
+public class EnglishDictOutboxDo {
+ @TableId(type = IdType.INPUT)
+ private String id;
+ private String eventType;
+ private Long entityId;
+ private String payload;
+ private Long createTime;
+ private Long retryAfter;
+ private Integer attempts;
+ private Long deliveredTime;
+ private String lastError;
+}
--- /dev/null
+++ b/infrastructure/repository/src/main/java/cn/jojo/cc/core/service/english/EnglishDictOutboxCoreService.java
@@ -0,0 +1,61 @@
+package cn.jojo.cc.core.service.english;
+
+import cn.jojo.cc.core.dao.english.EnglishDictOutboxDao;
+import cn.jojo.cc.core.domain.english.EnglishDictOutboxDo;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
+import jakarta.annotation.Resource;
+import org.springframework.stereotype.Service;
+import java.util.List;
+import java.util.UUID;
+
+/** 不切库:事务写入者和恢复者必须显式选择各自单库。 */
+@Service
+public class EnglishDictOutboxCoreService {
+ @Resource
+ private EnglishDictOutboxDao dao;
+
+ public String enqueue(String type, Long entityId, String payload) {
+ if (payload == null || payload.length() > 8000) {
+ throw new IllegalArgumentException("outbox payload 必须在 8000 字符内");
+ }
+ EnglishDictOutboxDo row = new EnglishDictOutboxDo();
+ row.setId(UUID.randomUUID().toString());
+ row.setEventType(type);
+ row.setEntityId(entityId);
+ row.setPayload(payload);
+ row.setCreateTime(System.currentTimeMillis() / 1000);
+ row.setRetryAfter(0L);
+ row.setAttempts(0);
+ row.setDeliveredTime(0L);
+ if (dao.insert(row) != 1) { throw new IllegalStateException("outbox insert failed"); }
+ return row.getId();
+ }
+
+ public List