# 597084 统一修复逻辑

固定基线 `0baa9ebe8b7f9ecc46f675acbf1f56ac65378957`。一份code树与统一fix.patch，覆盖17正式+2条件；不改原分析快照，不部署。初轮R1–R5和后续R4来源错位均给出实际接线代码，最终独立复审规格/质量PASS（REVIEW-FINAL.md，代码与本轮设计层面）；隔离通过不是业务验收。

## AUDIO-N01

音频自动发布使用独立入口，在与人工保存/发布相同的实体锁内重读status和hasPublished；只有当前PUBLISHED且曾发布才复用完整发布。草稿返回未发布结果，PROD旧版本保留。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java:108` — `public PublishResultBo publishAudioWord`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java:119` — `public PublishResultBo publishAudioPhrase`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java:218` — `private void publishReleased`

验证：新增EnglishAudioPublishGuardRegressionTest（未执行）；锁外旧快照/锁内草稿路径已静态核对。

## AUDIO-N02

总超时判定前置到queryTask之前，覆盖query异常、completed下载失败、running/null全部分支；cancel异常被单独接住，仍计失败并清在途任务。预算内下载失败保持任务，下轮重试。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java:262` — `private boolean handleInFlight`

验证：verification/red-audio.txt -> green-audio.txt；4个超时分支及预算内失败保留实跑。

## AUDIO-N03

submit成功不再清计数；终态failed/cancelled与超时每个已结束尝试计一次，只有成功回填或检测到人工已补齐时清计数。提交API抛错仍计数，7天冷却保持现有契约。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java:161` — `private void submit(`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java:262` — `private boolean handleInFlight`

验证：verification/green-audio.txt：终态累计、成功清零；submit计数路径静态核对。

## AUDIO-N04

三张表增加严格id>afterId的DAO查询入口。每张表用独立Redis游标跨调度推进；页满推进到最后候选，页不足归零，达到失败上限/非自动出身变形也不能永久占住最早一页。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java:115` — `private List<EnglishWordDo> pendingWords`
- `code/infrastructure/repository/src/main/java/cn/jojo/cc/core/service/impl/english/EnglishWordCoreServiceImpl.java:260` — `listAudioPendingSubmitAfterId`

验证：接口/实现/调用三处均已给出；未实跑Redis游标和真实数据库分页。

## AUDIO-N05

单词条件更新音频时同一SQL重算完整性：word、phonetic非空，存在活翻译且无空词性/译文。音频回填和TEACHING缓存失效、受控补发布事件同JY事务提交；变形按phraseId失效聚合详情。

- `code/infrastructure/repository/src/main/java/cn/jojo/cc/core/service/impl/english/EnglishWordCoreServiceImpl.java:307` — `public boolean markAudioCompleted`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishAudioCompletionTxService.java:25` — `public boolean complete`

验证：SQL/事务及outbox调用静态核对；完整性数据库UPDATE、缓存实际消费未执行。

## SEG01

首次发布不要求JY已有NORMAL结果。按正式题型先在JY保留唯一身份，再递减映射PROD骨架；发布及全量刷新缺行路径都调用同一成对建行服务。PROD禁止自行reserveIdentity/自增。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentSkeletonBizService.java:19` — `public void ensurePair`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentStateTxService.java:29` — `public QuestionSegmentDo reserveTeachingIdentity`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentRefreshBizServiceImpl.java:191` — `private boolean refreshQuestion`

验证：JY身份->PROD映射->INIT->刷新接线静态核对；跨库ID实值与调度未跑。

## SEG02

保存事务内仅登记事件，提交后独立失信，派生失败不回滚主体。输出生成前记录稳定canonical原FormData输入hash；分词在同一次原输入读取中取得文本、输入hash、本库formDataId/configId，并只绑定确由该输入生成的完整输出JSON hash。缓存URL/输入hash/formId及scalar完整输出hash来自同一OutputData行；输出hash在loadDB解析原输出后生成并存为scalar，Redis默认序列化丢嵌套null时不重算。RPC选URL时固定不可变凭据随本次请求传给delivery。库存四项身份与环境都相同才下发，v1响应不能被后读v2分词替换。缺身份安全省略；恢复Job必须每分钟持续调度，真实重生成、驱逐缓存、force重算，不能完整一轮后停用，绝不给旧URL现场贴新hash。总开关/执行急停在Job取锁前阻断，服务及重生成前复检。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentSaveHookService.java:61` — `public void hookSave`
- `code/application/src/main/java/cn/jojo/cc/biz/service/impl/schema/process/oss/GenerateOutPutDataService.java:84` — `String segmentSourceHash`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentPayloadIdentity.java:9` — `public record SegmentPayloadIdentity`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/DefaultSegmentTextExtractor.java:103` — `public RichTextSource readRichTextSource`
- `code/api/api-server/src/main/java/cn/jojo/cc/server/service/impl/QuestionServiceImpl.java:1847` — `SelectedSegmentOutput.capture`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentDeliveryBizService.java:76` — `Map<String, SelectedSegmentOutput> selectedOutputs`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentOutputIdentityRecoveryBizService.java:76` — `private void recover`

验证：verification/red-response-binding.txt -> green-response-binding.txt：完整生产Delivery/Identity/SelectedOutput+逐字RPC fill方法，外围替身。8项包括先选v1再DB/分词v2必须null、同v2正常、旧缓存拒绝、canonical null/数组/对象序。真实生成器/OSS/双库Job未跑。 verification/green-cache-roundtrip.txt使用真实BO/default serializer验证Redis往返null丢失下scalar仍一致，旧缺scalar拒绝；green-job-pause.txt执行完整Job，暂停0锁/0服务调用，正常对照1/1。

## SEG03

外层先检查executionEnabled，再经独立代理compute事务SELECT真实cc_new_question WHERE del_flag=0 FOR UPDATE，拒绝旧revision，改用锁内重读题目。跨内容位写入/空集合删除同题锁事务。异常先退出代理回滚释放锁，再由外层调用另一个REQUIRES_NEW事务重锁、核当前revision及可用正文指纹，登记FAILED/retry_count/last_error，绝不让失败登记随原事务回滚或持原锁再自锁。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTxService.java:22` — `public <T> T compute`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTxService.java:29` — `public void recordFailure`
- `code/infrastructure/repository/src/main/java/cn/jojo/cc/core/dao/english/QuestionSegmentDao.java:20` — `lockQuestion(`

验证：verification/red-segment-wave.txt -> green-segment-wave.txt，真实Spring事务拦截器的事件序列为begin/lock/rollback/begin/lock/failed/commit；内存事务manager及DAO替身，非真实MySQL锁/持久化验证。

## SEG04

数字统一按分隔符；nextToken、isWordToken、边界判定共用ASCII字母规则，避免cat2dog、3look/up4进入词典。撇号和hyphen仍仅两侧字母时保留。

- `code/common/src/main/java/cn/jojo/cc/common/utils/EnglishWords.java:31` — `public static boolean isWordChar`

验证：verification/red-words.txt -> green-words.txt（实际编译完整生产EnglishWords）。

## 01

CSV在EOF仍处于引号内时整文件拒绝，并报记录起始物理行；只允许字段开头开引号，闭引号后只允许逗号/换行/EOF。有效引号换行/双引号转义保留；记录行号改为起始物理行。文件在解析完成后才进入写入。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishImportFileReader.java:324` — `private List<Record> readRecords`

验证：verification/red-import.txt -> green-import.txt；完整Reader、Normalizer实际隔离javac。

## 02

一个非空variant非法就break且整行不进入rows；撤销该无效行的文件内firstSeen占用，后续同key修正行仍能导入。合法去重及与原形同串的变体丢弃保持。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishImportFileReader.java:163` — `boolean invalidVariant`

验证：verification/green-import.txt实际拒整行；新增JUnit覆盖同文件修正同key，未运行。

## 03

下载前校验显式limit必须为JSON整数类型，严格>0且<=Integer.MAX_VALUE；0、负数、小数、数字字符串、null、溢出拒绝。只有省略limit表示全文件。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictImportBizServiceImpl.java:353` — `Integer limit = null`

验证：Param原嵌套类隔离javac，green-import.txt；JUnit另列小数/溢出/null/string未运行。

## 06

发布PROD主从数据与失效事件同一PROD事务提交，JY改为PUBLISHED不会丢补偿身份。Job按retry_after扫描JY和PROD两库，使用真实sendN同步等broker确认；失败留待办并退避，短语版本推进用可抛错可靠入口。delivered_time保留确认记录。内部PublishResultBo保留pending，BatchPublishResultBo增加pendingCacheCount（包含于PROD successCount），Job显式记录待失效数；HTTP Boolean保持既有契约，仅表示PROD提交及失效待办登记，不表示跨实例消费完成。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictPublishProServiceHelper.java:141` — `englishDictOutboxCoreService.enqueue("WORD_CACHE"`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictOutboxBizService.java:28` — `public int replay`
- `code/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishDictOutboxJobHandler.java:15` — `@XxlJob`

验证：完整DDL/DO/DAO/Core/Biz/Job/真实调用已接线，未部署DDL/注册Job/运行broker。

## 10

文本锁内先查活词，再查含删除行；存在则直接复用，不查百度。仅确实不存在才查询百度并保存。文本锁释放后进入实体锁内验证自动补录、从未发布且auto_publish_eligible=1，才发布。新建补录置1，任何人工saveWord清0；不再用createTime==updateTime推断未编辑。新列在JY/PROD部署，存量默认0且禁止用旧审计时间回填资格。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/SegmentAutoFillBizServiceImpl.java:143` — `private void autoFillOne`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java:132` — `public PublishResultBo publishAutoFillWord`

验证：verification/red-eligibility.txt -> green-eligibility.txt：同秒flag0/null旧代码误发布、修复后拒绝；原方法逐字提取，外围替身。百度先幂等路径与人工清flag静态核对；真实百度/MQ未跑。

## 13

翻译实际落库值改为PartOfSpeechEnum的规范串，与原校验使用同一函数；重复n.等输入不再以超长原串写VARCHAR96。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictWriteTxService.java:533` — `target.setPos(PartOfSpeechEnum.normalizeKnownCodes`

验证：静态确认写入点已用规范值；未执行真实MySQL严格模式。

## 14

单词与短语管理精确搜索都使用保存时相同TextNormalizer：小写、trim、连续空白压单空格。

- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPhraseBizServiceImpl.java:129` — `TextNormalizer.normalize(bo.getPhrase()), bo.getStatus()`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishWordBizServiceImpl.java:116` — `TextNormalizer.normalize(bo.getWord()), bo.getStatus()`

验证：TextNormalizer复用和两个调用点静态核对；未运行管理API。

## 23

纯数字/键值limit都严格正整数，非空参数必须至少解析一个有效键值；显式0和纯逗号参数在任何publish调用前失败。空参数及未给limit仍按原规则全量。

- `code/api/admin-worker/src/main/java/cn/jojo/cc/worker/task/EnglishBatchPublishTask.java:190` — `private static int positiveLimit`

验证：verification/red-batch.txt -> green-batch.txt；完整Task实际隔离javac，非法参数零写，150/2限量正确。

## COND-IPA

识别Unicode IPA字符块及有边界的/ASCII音标/；跳过括号中可识别IPA，不把and/or中的斜杠当音标起点；[ordinary words]保留。无法可靠区分无标记纯ASCII音标，明确保留。 DefaultSegmentTextExtractor保留原IPA token交统一扫描器处理，避免先剔IPA字母留下ASCII碎片；旧fixture已相应迁移。

- `code/common/src/main/java/cn/jojo/cc/common/utils/EnglishWords.java:195` — `private static int phoneticSpanEnd`

验证：verification/green-words.txt覆盖/kæt/、[dɒɡ]、/kat/、and/or与普通方括号；未标记纯ASCII仍是条件边界。

## COND-INDEX

保留read readySnapshot的旧索引降级；持久化分词专用freshSnapshotForWrite强制版本比对，再确认句柄version等于实际远端，Redis不可读/重装失败/unknown版本返回null跳过落库及补录。

- `code/application/src/main/java/cn/jojo/cc/biz/cache/english/EnglishPhraseIndexCacheService.java:190` — `public PhraseIndexSnapshot freshSnapshotForWrite`
- `code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentBizServiceImpl.java:269` — `freshSnapshotForWrite(segmentSource)`

验证：读写入口分离和校验静态核对；Redis失败与多实例重装未实跑。

## 完整关键代码

下列为交付源码原文；其它完整文件均位于code/，没有伪接口或TODO集成占位。

### `public PublishResultBo publishAudioWord`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java`

```java
    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();
        });
    }
```

### `public PublishResultBo publishAudioPhrase`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java`

```java
    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();
            });
    }
```

### `public PublishResultBo publishAutoFillWord`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishPublishBizServiceImpl.java`

```java
    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())
                && Integer.valueOf(1).equals(current.getAutoPublishEligible());
            return eligible ? doPublishWord(current, operator) : new PublishResultBo();
        });
    }
```

### `private boolean handleInFlight`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/impl/EnglishAudioBackfillBizServiceImpl.java`

```java
    private boolean handleInFlight(String table, Long id, String taskId, Long submitTime,
                                   Long currentAudioResourceId, long now, long timeoutSeconds,
                                   EnglishAudioBackfillResultBo result) {
        if (id == null || StringUtils.isBlank(taskId)) {
            return false;
        }
        result.setScanned(result.getScanned() + 1);
        String rowKey = table + ":" + id;
        long elapsed = submitTime == null ? Long.MAX_VALUE : Math.max(0L, now - submitTime);
        if (elapsed >= timeoutSeconds) {
            // 总生命周期预算适用于查询异常、completed 下载失败等全部分支；取消失败也必须本地收口。
            try { internalTtsClient.cancelTask(taskId); }
            catch (Exception e) { log.warn("取消超时音频任务失败, taskId={}", taskId, e); }
            increaseFailCount(rowKey);
            markCleared(table, id);
            result.setFailed(result.getFailed() + 1);
            return false;
        }
        TtsTaskStatus status;
        try {
            status = internalTtsClient.queryTask(taskId);
        } catch (Exception e) {
            // 查状态失败按「还没好」处理：下轮再查，不在这里清任务号
            log.warn("音频任务状态查询失败, row={}:{}, taskId={}", table, id, taskId, e);
            result.setRunning(result.getRunning() + 1);
            return false;
        }

        if (status == null || status.isRunning()) {
            result.setRunning(result.getRunning() + 1);
            return false;
        }

        if (status.isCompleted()) {
            // 人工优先：在途期间运营可能已上传了音频，这时只清任务号，既不下载也不覆盖
            if (currentAudioResourceId != null && currentAudioResourceId > 0) {
                markCleared(table, id);
                clearFailCount(rowKey);
                result.setCompleted(result.getCompleted() + 1);
                log.info("音频任务完成但该行已有音频，保留人工音频并清任务号, row={}:{}", table, id);
                return true;
            }
            Long resourceId = englishAudioBizService.saveAudio(taskId);
            if (resourceId != null) {
                if (!markCompleted(table, id, resourceId)) {
                    // 条件更新未命中：写完前的一瞬间人工补了音频（并发窗口），同样只清任务号，不回写
                    markCleared(table, id);
                    log.info("音频回填时发现该行已被人工补齐，保留人工音频, row={}:{}", table, id);
                }
                clearFailCount(rowKey);
                result.setCompleted(result.getCompleted() + 1);
                return true;
            }
            // 下载或落库失败（未就绪/源文件过期/校验失败）：保留任务号，由超时清理最终收口，
            // 不在这里清掉——清掉就意味着这个词再也不会自动补音频
            result.setFailed(result.getFailed() + 1);
            log.warn("音频下载或保存失败，保留任务号待下轮, row={}:{}, taskId={}", table, id, taskId);
            return false;
        }

        // 整个尝试失败才累计，成功 submit 不重置计数。
        increaseFailCount(rowKey);
        markCleared(table, id);
        result.setFailed(result.getFailed() + 1);
        log.warn("音频任务终态失败, row={}:{}, taskId={}, status={}, error={}",
            table, id, taskId, status.getStatus(), status.getError());
        return false;
    }
```

### `public boolean complete`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishAudioCompletionTxService.java`

```java
    public boolean complete(String table, Long id, Long resourceId) {
        boolean changed;
        if ("word".equals(table)) {
            changed = words.markAudioCompleted(id, resourceId);
            if (changed) {
                EnglishWordDo word = words.getById(id, 0L);
                outbox.enqueue("AUDIO_WORD", id, JSON.toJSONString(
                    EnglishDictCacheBoFactory.wordInvalidateBos(DataBaseTypeEnum.TEACHING, id,
                        null, word.getWordNormalized()), SerializerFeature.WriteClassName));
            }
        } else {
            Long phraseId = "phrase".equals(table) ? id
                : variants.mapPhraseIdByVariantIds(java.util.List.of(id), 0L).get(id);
            changed = "phrase".equals(table) ? phrases.markAudioCompleted(id, resourceId)
                : variants.markAudioCompleted(id, resourceId);
            if (changed && phraseId != null) {
                outbox.enqueue("AUDIO_PHRASE", phraseId, JSON.toJSONString(
                    EnglishDictCacheBoFactory.phraseInvalidateBos(DataBaseTypeEnum.TEACHING, phraseId),
                    SerializerFeature.WriteClassName));
            }
        }
        return changed;
    }
```

### `public <T> T compute`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTxService.java`

```java
    public <T> T compute(QuestionDo expected, Function<QuestionDo, T> action) {
        QuestionDo current = segments.lockQuestion(expected.getId());
        if (current == null || !Objects.equals(current.getUpdateTime(), expected.getUpdateTime())) { return null; }
        return action.apply(current);
    }
```

### `public void recordFailure`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentTxService.java`

```java
    public void recordFailure(QuestionDo attempted, String md5, Set<Integer> 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<Integer> 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);
        }
    }
```

### `public void ensurePair`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentSkeletonBizService.java`

```java
    public void ensurePair(Long teachingId, Long prodId) {
        QuestionDo prod = MultiDataSourceUtil.switchThen(DataSourceEnum.PROD, () -> questions.getById(prodId));
        if (prod == null) { return; }
        Integer type = SegmentContentTypeEnum.contentTypeOfQuestionType(prod.getQuestionType());
        if (type == null) { return; }
        QuestionSegmentDo identity = MultiDataSourceUtil.switchThen(DataSourceEnum.DEFAULT,
            () -> state.reserveTeachingIdentity(teachingId, type));
        if (identity == null || identity.getId() == null) {
            throw new IllegalStateException("缺少教研题目，禁止在 PROD 自增创建分词身份");
        }
        Long mappedId = CommonService.decrementAndGet(identity.getId());
        MultiDataSourceUtil.switchThen(DataSourceEnum.PROD, () -> {
            state.installProdSkeleton(prodId, mappedId, type);
            return null;
        });
    }
```

### `private List<Record> readRecords`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishImportFileReader.java`

```java
    private List<Record> readRecords(String content) {
        List<Record> records = new ArrayList<>();
        List<String> cells = new ArrayList<>();
        StringBuilder field = new StringBuilder();
        boolean inQuotes = false;
        boolean closedQuote = false;
        boolean fieldStarted = false;
        int rowNo = 1;
        int recordStart = 1;
        for (int i = 0; i < content.length(); i++) {
            char c = content.charAt(i);
            if (inQuotes) {
                if (c == '"') {
                    if (i + 1 < content.length() && content.charAt(i + 1) == '"') {
                        field.append('"');
                        i++;
                    } else {
                        inQuotes = false;
                        closedQuote = true;
                    }
                } else {
                    if (c == '\n') { rowNo++; }
                    field.append(c);
                }
                continue;
            }
            if (c == ',') {
                cells.add(field.toString());
                field.setLength(0);
                fieldStarted = false;
                closedQuote = false;
            } else if (c == '\r' || c == '\n') {
                if (c == '\r' && i + 1 < content.length() && content.charAt(i + 1) == '\n') { i++; }
                addRecord(records, cells, field, fieldStarted, recordStart);
                cells = new ArrayList<>();
                fieldStarted = false;
                closedQuote = false;
                recordStart = ++rowNo;
            } else if (c == '"' && !fieldStarted && !closedQuote) {
                inQuotes = true;
                fieldStarted = true;
            } else {
                if (c == '"' || closedQuote) {
                    throw BusinessException.paramException("CSV 第 " + recordStart + " 行引号位置非法");
                }
                field.append(c);
                fieldStarted = true;
            }
        }
        if (inQuotes) {
            throw BusinessException.paramException("CSV 第 " + recordStart + " 行引号未闭合");
        }
        addRecord(records, cells, field, fieldStarted, recordStart);
        return records;
    }
```

### `private int replayCurrentSource`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/dict/EnglishDictOutboxBizService.java`

```java
    private int replayCurrentSource(int limit) {
        int failed = 0;
        for (EnglishDictOutboxDo row : outbox.listDue(limit)) {
            try {
                rocketMqProducerService.sendN(CcNormalTagEnum.CLEAR_REDIS_COMMON_CACHE,
                    row.getPayload(), "english-dict-outbox-" + row.getId());
                if ("PHRASE_CACHE".equals(row.getEventType())) {
                    index.rebuildNowReliable(DataBaseTypeEnum.PROD, "outbox-" + row.getId());
                } else if ("AUDIO_WORD".equals(row.getEventType())) {
                    publisher.publishAudioWord(row.getEntityId(), new LoginUserBo(-1L, "admin"));
                } else if ("AUDIO_PHRASE".equals(row.getEventType())) {
                    publisher.publishAudioPhrase(row.getEntityId(), new LoginUserBo(-1L, "admin"));
                }
                outbox.acknowledge(row.getId());
            } catch (Exception e) {
                failed++;
                outbox.postpone(row, e.getClass().getSimpleName() + ": " + e.getMessage());
            }
        }
        return failed;
    }
```

### `private boolean saveResult`

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/QuestionSegmentBizServiceImpl.java`

```java
    private boolean saveResult(QuestionDo question, LoginUserBo operator, Integer contentType,
                               SegmentResult result) {
        QuestionSegmentDo segmentDo = new QuestionSegmentDo();
        segmentDo.setQuestionId(question.getId());
        segmentDo.setContentType(contentType);
        segmentDo.setQuestionKey(question.getQuestionKey());
        segmentDo.setSourceTextMd5(result.getSourceTextMd5());
        segmentDo.setQuestionRevision(question.getUpdateTime());
        // 落库只存文本与类型、不存词条 ID：ID 由下发时按当前词典现算（理由见 SegmentSnapshot 类注释）。
        SegmentSnapshot snapshot = SegmentSnapshot.from(result.getSegments());
        SegmentPayloadIdentity identity = result.getSourceIdentity();
        if (identity != null && identity.known()) {
            snapshot.setSourcePayloadHash(identity.payloadHash());
            snapshot.setSourceFormDataId(identity.formDataId());
            snapshot.setSourceContentConfigId(identity.contentConfigId());
            snapshot.setSourceOutputPayloadHash(identity.outputPayloadHash());
        }
        String storedJson = JSON.toJSONString(snapshot);
        if (storedJson.length() > 3000) {
            throw new IllegalStateException("segments超过VARCHAR3000，保留FAILED而非截断");
        }
        segmentDo.setSegments(storedJson);
        segmentDo.setSegmentStatus(SegmentStatusEnum.NORMAL.getCode());
        segmentDo.setRetryCount(0);
        if (operator != null) {
            // V3 审计只存操作人 ID（新表无人名列），人名仅存在于登录态/日志。
            segmentDo.setCreatorId(operator.getLoginUserId());
            segmentDo.setOperatorId(operator.getLoginUserId());
        }
        return questionSegmentCoreService.upsert(segmentDo);
    }
```

### SegmentPayloadIdentity

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SegmentPayloadIdentity.java`

```java
package cn.jojo.cc.biz.service.english.segment;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import cn.jojo.cc.biz.utils.MD5Util;
import java.util.*;

/** 原始FormData生成输入的v1稳定身份；ID独立比较，不把别库ID混入正文hash。 */
public record SegmentPayloadIdentity(String payloadHash, Long formDataId, Long contentConfigId, String outputPayloadHash) {
    public SegmentPayloadIdentity(String hash, Long formId, Long configId) { this(hash, formId, configId, null); }
    public boolean known() { return inputKnown() && outputPayloadHash != null
        && outputPayloadHash.matches("output-v1:[a-f0-9]{32}"); }
    public boolean inputKnown() {
        return payloadHash != null && payloadHash.matches("form-v1:[a-f0-9]{32}")
            && formDataId != null && contentConfigId != null;
    }
    public static String hash(JSON formData) {
        if (formData == null) { return null; }
        return "form-v1:" + MD5Util.creatMd5(JSON.toJSONString(canonical(formData),
            SerializerFeature.MapSortField, SerializerFeature.WriteMapNullValue));
    }
    public static String outputHash(JSON output) {
        if (output == null) { return null; }
        return "output-v1:" + MD5Util.creatMd5(JSON.toJSONString(canonical(output),
            SerializerFeature.MapSortField, SerializerFeature.WriteMapNullValue));
    }
    private static Object canonical(Object value) {
        if (value instanceof Map<?, ?> map) {
            Map<String, Object> result = new TreeMap<>();
            map.forEach((k, v) -> result.put(String.valueOf(k), canonical(v)));
            return result;
        }
        if (value instanceof Iterable<?> list) {
            List<Object> result = new ArrayList<>();
            list.forEach(item -> result.add(canonical(item)));
            return result;
        }
        return value;
    }
}

```

### SelectedSegmentOutput

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/SelectedSegmentOutput.java`

```java
package cn.jojo.cc.biz.service.english.segment;

import cn.jojo.cc.common.enums.DataBaseTypeEnum;

/** 本次RPC已选输出的不可变凭据，与返回URL来自同一个缓存BO；绝不现场贴新DB身份。 */
public record SelectedSegmentOutput(DataBaseTypeEnum source, String outputUrl, SegmentPayloadIdentity identity) {
    public boolean known() { return source != null && outputUrl != null && !outputUrl.isBlank()
        && identity != null && identity.known(); }
    public static SelectedSegmentOutput capture(DataBaseTypeEnum source, Long selectedConfigId,
        cn.jojo.cc.biz.model.cache.FormAndDataCacheBo selected, String responseUrl) {
        if (selected == null || !java.util.Objects.equals(selectedConfigId, selected.getBusinessId())
            || !java.util.Objects.equals(responseUrl, cn.jojo.cc.biz.utils.UriToolsUtil.getPath(selected.getOutDataJson()))) {
            return null;
        }
        return new SelectedSegmentOutput(source, responseUrl, new SegmentPayloadIdentity(
            selected.getSegmentSourcePayloadHash(), selected.getSegmentSourceFormDataId(), selectedConfigId,
            selected.getSegmentSourceOutputHash()));
    }
}

```

### EnglishSegmentOutputIdentityRecoveryBizService

`code/application/src/main/java/cn/jojo/cc/biz/service/english/segment/EnglishSegmentOutputIdentityRecoveryBizService.java`

```java
package cn.jojo.cc.biz.service.english.segment;

import cn.jojo.cc.biz.cache.FormAndDataCacheService;
import cn.jojo.cc.biz.cache.key.FormAndDataCacheKey;
import cn.jojo.cc.biz.model.bo.CommonCacheBo;
import cn.jojo.cc.biz.model.cache.FormAndDataCacheBo;
import cn.jojo.cc.biz.service.question.QuestionBizService;
import cn.jojo.cc.common.enums.DataBaseTypeEnum;
import cn.jojo.cc.common.enums.DataSourceEnum;
import cn.jojo.cc.common.enums.RedisKeyEnum;
import cn.jojo.cc.common.enums.question.FormDataBusinessTypeEnum;
import cn.jojo.cc.common.enums.question.BusinessTypeClassifyEnum;
import cn.jojo.cc.common.enums.question.OutPutDataTypeEnum;
import cn.jojo.cc.core.bo.LoginUserBo;
import cn.jojo.cc.core.domain.question.library.QuestionDo;
import cn.jojo.cc.core.domain.schema.common.data.OutputDataDo;
import cn.jojo.cc.core.multidatasource.MultiDataSourceUtil;
import cn.jojo.cc.core.service.question.library.QuestionCoreService;
import cn.jojo.cc.core.service.schema.OutputDataCoreService;
import cn.jojo.cc.core.service.english.EnglishDictOutboxCoreService;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.serializer.SerializerFeature;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.Objects;

/** 持续来源恢复：旧输出及后续跨库身份失配必须真实重生成，不能给旧URL贴新输入hash。 */
@Service
@Slf4j
public class EnglishSegmentOutputIdentityRecoveryBizService {
    @Resource private cn.jojo.cc.biz.common.config.EnglishDictConfig englishDictConfig;
    @Resource private QuestionCoreService questions;
    @Resource private QuestionBizService questionBizService;
    @Resource private OutputDataCoreService outputs;
    @Resource private SegmentContentSnapshotService content;
    @Resource private QuestionSegmentBizService segments;
    @Resource private EnglishSegmentSkeletonBizService skeletons;
    @Resource private EnglishDictOutboxCoreService outbox;
    @Resource private RedissonClient redissonClient;

    public String runOnce(LoginUserBo operator) {
        if (paused()) { return "paused: no cursor/read/regeneration/upload"; }
        int failed = 0;
        StringBuilder summary = new StringBuilder();
        for (DataBaseTypeEnum env : new DataBaseTypeEnum[]{DataBaseTypeEnum.TEACHING, DataBaseTypeEnum.PROD}) {
            DataSourceEnum dataSource = env == DataBaseTypeEnum.PROD ? DataSourceEnum.PROD : DataSourceEnum.DEFAULT;
            var cursor = redissonClient.getAtomicLong(RedisKeyEnum.ENGLISH_SEGMENT_REFRESH_LOCK.buildKey()
                + ":output-source-cursor:" + env.name());
            long after = cursor.get();
            List<QuestionDo> batch = MultiDataSourceUtil.switchThen(dataSource,
                () -> questions.listSegmentCandidatesByIdGt(after, 50, env));
            for (QuestionDo question : batch) {
                if (paused()) { return summary.append("paused; cursor retained").toString(); }
                try {
                    MultiDataSourceUtil.switchThen(dataSource, () -> { recover(question, env, operator); return null; });
                } catch (Exception e) {
                    failed++;
                    log.error("输出来源恢复失败，保留fail-closed并推进后续题, env={}, id={}", env, question.getId(), e);
                }
                cursor.set(question.getId());
            }
            boolean finished = batch.size() < 50;
            if (finished) {
                cursor.set(0L);
                log.info("输出身份恢复完整扫描一圈, env={}, lastId={}, 本轮失败={}; 持续Job下一轮重新扫描", env, after, failed);
            }
            summary.append(env).append(" scanned=").append(batch.size()).append(" finished=").append(finished).append("; ");
        }
        return summary.append("failed=").append(failed).toString();
    }

    private void recover(QuestionDo question, DataBaseTypeEnum env, LoginUserBo operator) {
        if (paused()) { return; }
        SegmentPayloadIdentity identity = content.readSource(question).identity();
        if (identity == null || !identity.inputKnown()) { throw new IllegalStateException("原FormData不可用，不能伪造输出身份"); }
        if (!hasMatchingOutput(identity)) {
            // 现有公开入口按真实schema重生成并上传输出，genOutPutData在生成前记录输入身份。
            if (paused()) { return; }
            questionBizService.refreshGenStandQuestion(question.getId(), operator);
            identity = content.readSource(question).identity();
            if (identity == null || !identity.inputKnown() || !hasMatchingOutput(identity)) {
                throw new IllegalStateException("输出未能按当前输入重生成，保持不可点击");
            }
        }
        FormAndDataCacheKey key = new FormAndDataCacheKey(identity.contentConfigId(),
            FormDataBusinessTypeEnum.STANDARD_QUESTION.getType(),
            BusinessTypeClassifyEnum.STANDARD_QUESTION_CONTENT.getType(), OutPutDataTypeEnum.GAME.getType());
        CommonCacheBo<FormAndDataCacheKey, FormAndDataCacheBo> evict =
            CommonCacheBo.<FormAndDataCacheKey, FormAndDataCacheBo>builder().bizType(QuestionDo.class)
                .bizId(question.getId()).ids(Set.of(key)).type(FormAndDataCacheService.class).build();
        outbox.enqueue("FORM_OUTPUT_CACHE", question.getId(), JSON.toJSONString(List.of(evict), SerializerFeature.WriteClassName));
        if (env == DataBaseTypeEnum.PROD) {
            skeletons.ensurePair(cn.jojo.cc.biz.service.component.CommonService.incrementAndGet(question.getId()), question.getId());
        }
        segments.refreshOne(question, env, operator);
    }

    private boolean paused() {
        return !englishDictConfig.isSegmentEnabled() || !englishDictConfig.isSegmentExecutionEnabled();
    }

    private boolean hasMatchingOutput(SegmentPayloadIdentity identity) {
        return outputs.listByFormDataIdsAndType(List.of(identity.formDataId()),
            OutPutDataTypeEnum.GAME.getType(), 0).stream().anyMatch(output ->
                Objects.equals(identity.payloadHash(), output.getSegmentSourcePayloadHash())
                    && output.getUrl() != null && !output.getUrl().isBlank());
    }
}

```

## R4来源绑定与恢复的具体集成

1. GenerateOutPutDataService在schema转换和media增强之前，针对STANDARD_QUESTION+STANDARD_QUESTION_CONTENT采集原FormData输入hash。canonical递归排序对象key、保留显式null与数组顺序，版本前缀form-v1；生成与计算调用同一公共函数。非该业务输出流程不改数据内容。
2. OutputDataDo新增segment_source_payload_hash VARCHAR(40)，没有存完整payload。URL、outputData和输入hash同一输出行提交；listSimple的显式投影已补列，两个FormDataBizService缓存装配入口从同一选定行取URL/JSON/hash/formDataId。FormAndDataCacheBo保留原六参构造兼容。
3. 分词抽取器按真实ContentNormConfig业务键读FormData，一次读取文本与输入身份；再认同一formDataId且输入hash相符的实际GAME输出，记录完整输出JSON的output-v1 hash。库存segments JSON只加hash/本库formDataId/configId小字段，仍严格受3000长度门，超长走既有FAILED收口。
4. RPC的listGenStandQuestion在设置本次questionInfo.data后，立刻捕获同一cache BO的不可变SelectedSegmentOutput（直接复制loadDB产生的scalar输出hash，不对Redis嵌套JSON重算）放入请求局部map；fillSegments传这个map。下发不重新读取当前DB代替本次输出；没有凭据的旧签名安全返回空。
5. 身份校验包括环境、原输入hash、完整输出hash、本库formDataId和contentConfigId。输出结构由动态schema生成，因此没有假设它与FormData同shape，也不在响应路径下载远端JSON。UploadService无指定fileKey的上传会使用idGenerator.nextId产生对象key，既有生成路径不是按固定文件名覆盖URL。
6. JY/PROD的ID分别从本库表单和本次缓存取得；不在输出元数据内写JY身份ID。既有OutputData.formDataId仍按@TableRelation映射；JSON库存由本库重算，不把JY ID塞进PROD。若发布时原输入中的资源ID等映射使输入hash变化，旧输出来源不能冒认一致，必须在PROD真实重生成后恢复。
7. 两库先执行三份DDL：597084-english-dict-outbox.sql、597084-autofill-eligibility.sql、597084-output-source-identity.sql。新API/worker和生成器滚动升级期间，旧缓存/库存没有来源字段就省略segments；不能现场拿新DB hash贴到旧URL。
8. 新增englishSegmentOutputIdentityRecover作业，每轮JY/PROD各50候选题、持久ID游标、共用现有分词刷新锁。真实调用已有refreshGenStandQuestion生成/上传输出（没有强制切库注解，沿Job选定库开启生成事务），验证输出输入hash匹配后，登记FORM_OUTPUT_CACHE outbox驱逐对应FormAndDataCacheKey，再force重算。失败推进游标不阻塞后续题，该Job必须每分钟持续调度，完整一轮后游标归零继续扫描；失败项下轮再次覆盖，不能停用后只靠每日分词恢复。
9. 原有englishDictOutboxReplay每分钟消费包含FORM_OUTPUT_CACHE在内的失效待办；broker确认不等于所有消费实例已完成。恢复Job真实写OSS/DB，部署时须配置每分钟持续运行并观察失败日志；本次没有运行它。
10. 单题已有多个内容配置时库存仍只有当前实际计算配置的身份；其它所选配置不匹配时只省略segments。没有把多个配置混成一个序列；多配置完全同时服务超出现行每题/内容位单行模型，应作为明确架构边界复核。正常单配置的同v2对照在探针中可下发。

## 其它设计裁决与材料替换文案

- 草稿：Spec17应写“实体锁内当前PUBLISHED且hasPublished=1才音频补发布；人工草稿仅JY回填，PROD保持旧版直到人工发布”。
- 首次分词：Spec发布段应写“JY保留稳定身份后映射PROD INIT，由正式词典和已发布题目重算，缺行全量同路恢复”，不再写搬segments。
- 最新PRD的原形/变体联合查重及内部撇号不切优先；数字是分隔符，条件IPA保留无标记纯ASCII不可可靠分类的边界。
- 音频格式沿当前Spec5.1 mp3/wav门槛，删除5.2/旧Plan02“无格式校验”的矛盾文字；本次不擅自放开既有门。
- 百度故障按现实现降级壳词，属于降级新建、完整性有缺失，不虚报整词失败；重导跳过已有壳，异步任务只补音频。产品若要全词失败须另行变更降级策略。
- 读索引可旧快照降级；持久写必须确认远端版本一致。
- HTTP保持DefaultResult<Boolean>，true表示PROD提交及失效待办登记，不表示所有实例即时可见；内部pendingCacheCount与Job日志分开记录。返回结构变更应显式迁移。
- 自动补录新建auto_publish_eligible=1，任何人工save清0；历史默认0，不能用审计秒时间相等回填资格。source原业务含义不变。
- 原计算Tx回滚释放题锁之后，独立事务重锁登记失败；保存主体事务没有派生表操作。

## 持续来源恢复调度与真实验证边界

- `englishSegmentOutputIdentityRecover`和`englishDictOutboxReplay`都是生产持续依赖，建议每分钟调度，扫描完整一轮后不得停用。后续含TABLE_RELATION资源ID的正常发布仍可使JY输入hash与映射后PROD输入hash不同，必须由持续恢复者真实重生成。
- 每库每轮最多50候选，N候选的完整扫描/游标确认归零按`ceil(N/50)+1`个成功调度轮加执行时间保守估算（整除50时需额外空页）；实际锁跳过、暂停、故障及失败重试会延长，不存在无条件墙钟上界，不承诺所有发布一分钟或秒级恢复。
- Job入口总开关或execution.enabled关闭时，在读游标/取锁前返回；服务与每题重生成/OSS写入前再次检查。已发出的外部请求不宣称可瞬间撤销。
- `segmentSourceOutputHash`在两个loadDB装配入口解析同一OutputData.outputData后产生并作为BO scalar保存。Redis默认JSON序列化可能丢掉outDataJsonObj显式null，capture绝不从这个有损对象重算hash。旧缓存缺scalar只失信并等待outbox驱逐/真实输出恢复。
- `verification/green-cache-roundtrip.txt`执行真实FormAndDataCacheBo与默认JSON序列化往返，已确认嵌套null确实丢失而scalar身份仍一致；`green-job-pause.txt`执行完整Job，两个暂停门0锁/0恢复调用，正常对照1/1。
