缺陷:编辑章节正文回到曾经用过的版本会返回 500(#10 后续) #32

Closed
opened 2026-09-15 12:23:52 +08:00 by ila · 3 comments
Owner

来源与目标

#13 真实链路验证时发现:把章节正文改回曾经编辑过的那个版本,接口返回 HTTP 500「服务暂不可用,请稍后再试」。这是 #10 编辑章节功能的既有缺陷,不属于 #13 范围,按工作流单独建单记录,未在 #13 中顺手修复。

复现(真实 API+MySQL,虚构账号):

  1. 粘贴一章,正文为版本 A(先以 paste 建立,任务键来自粘贴请求)。
  2. 编辑正文为版本 B(新 sha,创建任务);处理完成后再次编辑回版本 A。
  3. 再次编辑为版本 B → PATCH /api/v1/chapters/:id 返回 500。

Go 集成级最小复现(三行编辑):

edit to a new version: 200
edit back to the original version: 200
edit to the first new version again: 500 服务暂不可用,请稍后再试
job 519 sha 6a6d1475 status pending
job 520 sha 724576eb status pending
chapter now 724576eb pending

根因(已定位,未修改)

server/app/lexgo/edit.go 的 UpdateChapter 用「章节编号+内容摘要」派生任务键:

key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha))

而 lexgo_ingest_jobs 有唯一约束 uq_job_request (owner_id, request_key)。同一章回到同一内容版本时,派生出与更早任务完全相同的键,Create 触发 MySQL 1062 重复键,整个事务回滚并冒泡为通用 500。事务回滚保证了数据一致(章节仍保持上一次成功的版本),但用户看到的是不可解释的错误。

影响

  • 编辑正文后改回原文本(撤销输入、恢复备份正文、误改后还原)会永久 500,用户无法回到任何曾用版本。
  • 与并发无关,可稳定重放;只影响正文编辑,标题编辑与首次改成新内容正常。
  • 数据不会损坏:失败请求整体回滚,不产生重复章节或半更新状态。

建议方向(待确认后再定)

  1. 任务键加入单调版本序号(例如章节加 version 列或复用 updated_at),保证「同一内容可再次成为新版本」;需要 schema 变更与迁移测试。
  2. 或改为复用既有任务行:键命中且内容摘要一致时把该行重置为 pending、attempts=0、finished_at=NULL,不新增行;不需要 schema 变更,但要让 #10 的「旧版本任务不得复活新版本章节」门控继续成立。
  3. 不论哪种方案,都应返回明确的业务错误而不是 500,并补上「A→B→A→B」的集成回归用例。

依赖与执行

前置:#10 已完成。状态:待验收(2026-09-16,提交 cbb4840,PR #39;方案见评论 8210,实施与验证见最新评论)。

## 来源与目标 #13 真实链路验证时发现:把章节正文改回**曾经编辑过的那个版本**,接口返回 HTTP 500「服务暂不可用,请稍后再试」。这是 #10 编辑章节功能的既有缺陷,不属于 #13 范围,按工作流单独建单记录,未在 #13 中顺手修复。 复现(真实 API+MySQL,虚构账号): 1. 粘贴一章,正文为版本 A(先以 paste 建立,任务键来自粘贴请求)。 2. 编辑正文为版本 B(新 sha,创建任务);处理完成后再次编辑回版本 A。 3. 再次编辑为版本 B → `PATCH /api/v1/chapters/:id` 返回 500。 Go 集成级最小复现(三行编辑): ``` edit to a new version: 200 edit back to the original version: 200 edit to the first new version again: 500 服务暂不可用,请稍后再试 job 519 sha 6a6d1475 status pending job 520 sha 724576eb status pending chapter now 724576eb pending ``` ## 根因(已定位,未修改) `server/app/lexgo/edit.go` 的 `UpdateChapter` 用「章节编号+内容摘要」派生任务键: ```go key := contentSHA(fmt.Sprintf("edit:%d:%s", chapter.ID, sha)) ``` 而 `lexgo_ingest_jobs` 有唯一约束 `uq_job_request (owner_id, request_key)`。同一章回到同一内容版本时,派生出与更早任务完全相同的键,`Create` 触发 MySQL 1062 重复键,整个事务回滚并冒泡为通用 500。事务回滚保证了数据一致(章节仍保持上一次成功的版本),但用户看到的是不可解释的错误。 ## 影响 - 编辑正文后改回原文本(撤销输入、恢复备份正文、误改后还原)会永久 500,用户无法回到任何曾用版本。 - 与并发无关,可稳定重放;只影响正文编辑,标题编辑与首次改成新内容正常。 - 数据不会损坏:失败请求整体回滚,不产生重复章节或半更新状态。 ## 建议方向(待确认后再定) 1. 任务键加入单调版本序号(例如章节加 `version` 列或复用 `updated_at`),保证「同一内容可再次成为新版本」;需要 schema 变更与迁移测试。 2. 或改为复用既有任务行:键命中且内容摘要一致时把该行重置为 `pending`、`attempts=0`、`finished_at=NULL`,不新增行;不需要 schema 变更,但要让 #10 的「旧版本任务不得复活新版本章节」门控继续成立。 3. 不论哪种方案,都应返回明确的业务错误而不是 500,并补上「A→B→A→B」的集成回归用例。 ## 依赖与执行 前置:#10 已完成。状态:待验收(2026-09-16,提交 cbb4840,PR #39;方案见评论 8210,实施与验证见最新评论)。
Author
Owner

#32 修复方案确认与实施启动(2026-09-16)

用户 2026-09-16 指示「清掉 #32」,采纳本工单建议中方案 2(复用既有任务行,不改 schema);方案 1(任务键加入单调版本序号)不采用,因此本单没有数据库结构变化。

根因(已在建单时定位)

server/app/lexgo/edit.go 用「章节编号+内容摘要」派生编辑任务键:contentSHA("edit:<章节>:<内容摘要>"),而 lexgo_ingest_jobs 有唯一约束 uq_job_request (owner_id, request_key)。同一章回到同一内容版本时派生出与更早任务完全相同的键,Create 触发 MySQL 1062,整个事务回滚并冒泡为通用 500。事务回滚保证了数据一致,但用户看到的是不可解释的错误,且任何曾经用过的版本都无法再成为当前版本(改回原文本、撤销误改都会永久 500)。

修复方案(方案 2)

新增 stageEditJob:在事务内先按 (owner_id, request_key) 锁定查询既有任务行——

情况 处理
没有既有行 与原来一样创建新任务(pending)
有既有行且 content_sha256 与本次版本一致 复用该行:重置为 pending、attempts = 0、清空 error_reason 与 finished_at、刷新 updated_at;不新增行,因此也不会累积历史版本任务
有既有行但内容摘要不一致 键由章节与内容派生,理论上不可能;仍返回 409 而不是交出一个描述别的正文的任务(防御)

保持不变的门控:RetryIngestJob 仍然拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以旧版本任务不会把新版本章节拉回处理;worker 的认领条件(status = pending 且章节 content_sha256 与任务一致)依然成立;同一个内容版本重复处理是幂等的(#6/#9 已确认),因此复用并重置重试计数是安全的。

验证计划

  • Go 集成:复现 #32 的 A→B→A→B 序列,断言每一步都返回 200(此前第三步 500)、章节最终为 ready 且正文为最后一次编辑的内容、每个版本只保留一行任务且复用的行是 pending/attempts=0;补一条「同一版本的任务曾失败后再次成为当前版本时被复活」的用例。
  • 真实链路:在开发实例上对 fixture 书籍执行同一序列,确认 200 与最终正文。
  • 文档:Business-Rules 记录「同一版本可再次成为当前版本」的规则,Local-Development 记录缺陷、根因、修复与验证,Architecture 记录任务键的复用规则。

Gitea MCP 仍指向其他站点,沿用目标站点 API 回退;凭据仅从既有安全配置读入进程。

## #32 修复方案确认与实施启动(2026-09-16) 用户 2026-09-16 指示「清掉 #32」,采纳本工单建议中**方案 2(复用既有任务行,不改 schema)**;方案 1(任务键加入单调版本序号)不采用,因此本单**没有数据库结构变化**。 ### 根因(已在建单时定位) `server/app/lexgo/edit.go` 用「章节编号+内容摘要」派生编辑任务键:`contentSHA("edit:<章节>:<内容摘要>")`,而 `lexgo_ingest_jobs` 有唯一约束 `uq_job_request (owner_id, request_key)`。同一章回到同一内容版本时派生出与更早任务完全相同的键,`Create` 触发 MySQL 1062,整个事务回滚并冒泡为通用 500。事务回滚保证了数据一致,但用户看到的是不可解释的错误,且**任何曾经用过的版本都无法再成为当前版本**(改回原文本、撤销误改都会永久 500)。 ### 修复方案(方案 2) 新增 `stageEditJob`:在事务内先按 `(owner_id, request_key)` 锁定查询既有任务行—— | 情况 | 处理 | |---|---| | 没有既有行 | 与原来一样创建新任务(pending) | | 有既有行且 `content_sha256` 与本次版本一致 | **复用该行**:重置为 `pending`、`attempts = 0`、清空 `error_reason` 与 `finished_at`、刷新 `updated_at`;不新增行,因此也不会累积历史版本任务 | | 有既有行但内容摘要不一致 | 键由章节与内容派生,理论上不可能;仍返回 409 而不是交出一个描述别的正文的任务(防御) | 保持不变的门控:`RetryIngestJob` 仍然拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),所以**旧版本任务不会把新版本章节拉回处理**;worker 的认领条件(`status = pending` 且章节 `content_sha256` 与任务一致)依然成立;同一个内容版本重复处理是幂等的(#6/#9 已确认),因此复用并重置重试计数是安全的。 ### 验证计划 - Go 集成:**复现 #32 的 A→B→A→B 序列**,断言每一步都返回 200(此前第三步 500)、章节最终为 ready 且正文为最后一次编辑的内容、每个版本只保留一行任务且复用的行是 pending/attempts=0;补一条「同一版本的任务曾失败后再次成为当前版本时被复活」的用例。 - 真实链路:在开发实例上对 fixture 书籍执行同一序列,确认 200 与最终正文。 - 文档:Business-Rules 记录「同一版本可再次成为当前版本」的规则,Local-Development 记录缺陷、根因、修复与验证,Architecture 记录任务键的复用规则。 Gitea MCP 仍指向其他站点,沿用目标站点 API 回退;凭据仅从既有安全配置读入进程。
Author
Owner

#32 修复完成,待用户验收(2026-09-16)

用户 2026-09-16 指示「清掉 #32」,采纳本工单方案 2(复用既有任务行、不改 schema),契约见评论 8210。分支 fix/32-edit-version-reuse 从 main c293a41 创建,提交 cbb4840 已推送;PR #39 未合并,工单停在待用户验收。

修复与一处方案细化

  • 新增 server/app/lexgo/edit.go 的 stageEditJob:编辑产生新版本时,先按 (owner_id, chapter_id, content_sha256) 锁定查询该内容版本的任务行,找到就复用(重置 status=pending、attempts=0、清空 error_reason/finished_at),找不到才用派生键创建。
  • 方案细化:契约原本写的是「按派生键查已有行」,写回归用例时发现粘贴版本的任务行用的是粘贴请求键,因此按「键」复用会让第一次回退到粘贴版本仍多出一行(不报错,但同一内容出现两条任务)。改为按内容版本匹配后,一章的一个内容版本只保留一行任务,语义也更直白。
  • 保持不变:RetryIngestJob 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),旧版本任务不会把新版本章节拉回处理;正文未改动时不产生新版本也不新增任务;同一版本重复处理幂等。
  • 无 schema 变化、无需迁移;回退件 .local/lexgo-pre-issue32.exe。

验证

项 结果
go vet ./... / gofmt -l 通过、无输出
LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration 92 项顶层用例通过、0 跳过(新增 TestMySQLChapterEditBackToAPreviousVersion)
回归用例覆盖 A→B→A→B 每步 200(此前第三步 500);一章一个内容版本一行任务;复用的行干净重启(pending、attempts=0、无 finished_at 与 error_reason);上次失败的版本再次成为当前版本时被复活;章节最终 ready 且正文为最后一次编辑的内容;读者按该内容返回;正文未改动时不新增任务
真实 API+MySQL 21 项检查通过(.local/issue32-api-evidence.json):开发实例上对 fixture 书籍执行 A→B→A→B,三步全部 200 并各自最终 ready;该章只有两条任务行且都以 ready 收尾、没有任务停在 pending;读者返回最后一次编辑的正文;章节内容摘要等于某个任务版本;再切回另一版本仍 200 且仍是两行;删除 fixture 书籍后任务级联清空
治理 65 项与 check --strict 通过

文档

  • Architecture-and-Code-Map: f3ffc4fb7a75fccb147cb39ad3804f29be76b3e7
  • Business-Rules-and-Glossary: e248e18282d7d2599e1689007ed36b1f1c076267
  • Local-Development-and-Verification: 632b9d8b7c9951d05bf3c0b2f34f08da704e65dc
  • 镜像校验:sync --check 通过,3 个变更页逐字节正文比对一致

未验证

  • 只验证单章来回切换;多章并发编辑、超大文本反复切换、移动端编辑体验未单独压测。
  • 本单只改后端编排,未改前端;「改正文会重新处理这一章」的提示沿用 #10 的行为。

回退

无 schema 变化:停止 lexgo-api、恢复 .local/lexgo-pre-issue32.exe 重启即可;既有书籍与学习数据不受影响。

## #32 修复完成,待用户验收(2026-09-16) 用户 2026-09-16 指示「清掉 #32」,采纳本工单方案 2(复用既有任务行、**不改 schema**),契约见评论 [8210](https://git.ilapage.cn/OPC/lexgo/issues/32#issuecomment-8210)。分支 `fix/32-edit-version-reuse` 从 main `c293a41` 创建,提交 `cbb4840` 已推送;PR #39 未合并,工单停在待用户验收。 ### 修复与一处方案细化 - 新增 `server/app/lexgo/edit.go` 的 `stageEditJob`:编辑产生新版本时,先按 `(owner_id, chapter_id, content_sha256)` 锁定查询该内容版本的任务行,找到就**复用**(重置 `status=pending`、`attempts=0`、清空 `error_reason`/`finished_at`),找不到才用派生键创建。 - 方案细化:契约原本写的是「按派生键查已有行」,写回归用例时发现**粘贴版本的任务行用的是粘贴请求键**,因此按「键」复用会让第一次回退到粘贴版本仍多出一行(不报错,但同一内容出现两条任务)。改为**按内容版本匹配**后,一章的一个内容版本只保留一行任务,语义也更直白。 - 保持不变:`RetryIngestJob` 仍拒绝「任务内容摘要 ≠ 章节当前内容」的旧任务(409),旧版本任务不会把新版本章节拉回处理;正文未改动时不产生新版本也不新增任务;同一版本重复处理幂等。 - **无 schema 变化、无需迁移**;回退件 `.local/lexgo-pre-issue32.exe`。 ### 验证 | 项 | 结果 | |---|---| | `go vet ./...` / `gofmt -l` | 通过、无输出 | | `LEXGO_TEST_DB_NAME=lexgo_test_issue13 python scripts/server.py test-integration` | **92 项顶层用例通过、0 跳过**(新增 `TestMySQLChapterEditBackToAPreviousVersion`) | | 回归用例覆盖 | A→B→A→B 每步 200(此前第三步 500);一章一个内容版本一行任务;复用的行干净重启(pending、attempts=0、无 finished_at 与 error_reason);**上次失败的版本再次成为当前版本时被复活**;章节最终 ready 且正文为最后一次编辑的内容;读者按该内容返回;正文未改动时不新增任务 | | 真实 API+MySQL | **21 项检查通过**(`.local/issue32-api-evidence.json`):开发实例上对 fixture 书籍执行 A→B→A→B,三步全部 200 并各自最终 ready;该章只有两条任务行且都以 ready 收尾、没有任务停在 pending;读者返回最后一次编辑的正文;章节内容摘要等于某个任务版本;再切回另一版本仍 200 且仍是两行;删除 fixture 书籍后任务级联清空 | | 治理 | 65 项与 `check --strict` 通过 | ### 文档 - Architecture-and-Code-Map: `f3ffc4fb7a75fccb147cb39ad3804f29be76b3e7` - Business-Rules-and-Glossary: `e248e18282d7d2599e1689007ed36b1f1c076267` - Local-Development-and-Verification: `632b9d8b7c9951d05bf3c0b2f34f08da704e65dc` - 镜像校验:`sync --check` 通过,3 个变更页逐字节正文比对一致 ### 未验证 - 只验证单章来回切换;多章并发编辑、超大文本反复切换、移动端编辑体验未单独压测。 - 本单只改后端编排,未改前端;「改正文会重新处理这一章」的提示沿用 #10 的行为。 ### 回退 无 schema 变化:停止 lexgo-api、恢复 `.local/lexgo-pre-issue32.exe` 重启即可;既有书籍与学习数据不受影响。
Author
Owner

#32 验收通过(2026-09-16)

用户 2026-09-16 回复「#32 #40 验收通过、#42 验收通过」,本单验收结论如下。

  • 合入:PR #39 以 squash 方式合入 main,提交 f565c1c。
    因该分支携带两个仅改核心镜像的过程提交,rebase 逐提交重放会冲突;按"镜像以线上 Wiki 为准"处理,
    最终以 squash 落成单个提交,未改写任何已推送分支的历史。
  • 最终行为:编辑正文回到曾经用过的版本时,复用该内容版本已有的处理任务行(按
    owner_id + chapter_id + content_sha256 匹配,取最早一行,重置为 pending、清零尝试次数并清空失败原因),
    不再出现 500;正文未变不新建任务,同版本重复提交保持幂等,失败过的旧版本重新发布会被复活。
  • 测试:Go 集成测试 A→B→A→B 全链路通过(当时 92 项全绿);本单无 schema 变化。
  • 文档:Architecture f3ffc4fb、Business-Rules e248e182、Local-Development 632b9d8b 已在实施阶段发布;
    验收记录写入 Home e6e3b2a9 与 Project-Profile a482d5ba,核心镜像随验收提交 42945de 导出并通过
    harness.py sync --check。
  • 遗留:无。相邻问题(如需)另行建单。

工单关闭。

## #32 验收通过(2026-09-16) 用户 2026-09-16 回复「#32 #40 验收通过、#42 验收通过」,本单验收结论如下。 - 合入:PR [#39](https://git.ilapage.cn/OPC/lexgo/pulls/39) 以 **squash** 方式合入 main,提交 `f565c1c`。 因该分支携带两个仅改核心镜像的过程提交,rebase 逐提交重放会冲突;按"镜像以线上 Wiki 为准"处理, 最终以 squash 落成单个提交,**未改写任何已推送分支的历史**。 - 最终行为:编辑正文回到曾经用过的版本时,复用该内容版本已有的处理任务行(按 `owner_id + chapter_id + content_sha256` 匹配,取最早一行,重置为 pending、清零尝试次数并清空失败原因), 不再出现 500;正文未变不新建任务,同版本重复提交保持幂等,失败过的旧版本重新发布会被复活。 - 测试:Go 集成测试 A→B→A→B 全链路通过(当时 92 项全绿);本单无 schema 变化。 - 文档:Architecture `f3ffc4fb`、Business-Rules `e248e182`、Local-Development `632b9d8b` 已在实施阶段发布; 验收记录写入 Home `e6e3b2a9` 与 Project-Profile `a482d5ba`,核心镜像随验收提交 `42945de` 导出并通过 `harness.py sync --check`。 - 遗留:无。相邻问题(如需)另行建单。 工单关闭。
ila closed this issue 2026-09-16 21:58:13 +08:00
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: OPC/lexgo#32