Skip to content

fix(backend): 修复章节导出成品在 24 小时有效期内被清理删除的问题 - #385

Merged
syrizelink merged 2 commits into
syrizelink:mainfrom
MaxFreedomPollard:fix/chapter-export-cleanup-ttl
Oct 6, 2026
Merged

syrizelink merged 2 commits into
syrizelink:mainfrom
MaxFreedomPollard:fix/chapter-export-cleanup-ttl

Conversation

@MaxFreedomPollard

Copy link
Copy Markdown
Contributor

PR 标题

fix(backend): 修复章节导出成品在 24 小时有效期内被清理删除的问题

变更说明

  • 问题: backend/app/chapter_export/service.py 的 cleanup_chapter_export_files() 里两个分支的判断顺序反了。第 415 行先判断 if path.suffix in {".part", ".txt"},第 421 行才在 elif 里判断 path.suffix == ".txt" and job.status == JOB_STATUS_SUCCEEDED。但循环体只有通过第 460 行 _job_id_from_export_path() 的文件才会走到这里,而它已经把后缀限定成 .part 或 .txt 了,所以第一个条件恒为真,带 expires_at 有效期判断的 elif 分支是永远进不去的死代码。任务成功后状态是 succeeded,不在 {pending, running, cancel_requested} 里,should_keep 于是为假,成品 .txt 被直接 unlink。
  • 重要性: 这个清理由 backend/app/background/runtime/watchdog.py 第 48 行每 max(background_running_stale_seconds / 2, 1.0) 秒调用一次(默认 600 / 2 即 300 秒),main.py 的启动维护阶段还会再跑一次。所以导出任务成功后最多五分钟,EXPORT_FILE_TTL 承诺的 24 小时下载窗口就作废了:is_export_download_available() 因为 output_path.is_file() 为假返回 False,状态接口不再带 download_url,直接请求下载接口返回 409「导出文件不可用或已过期」。用户看到的现象是刚导完的文件放一会儿就下载不了,只能重新导出。
  • 变化: 把判断改成先看任务状态、再看后缀。任务仍在 pending / running / cancel_requested 时 .part 与 .txt 都保留,这一条维持原语义不变:write_chapter_export() 第 388 行用 os.replace 把 .part 换成 .txt,成品落盘可能早于任务状态提交,已有用例 test_cleanup_keeps_output_while_export_is_still_running 防的就是这个竞态。其余情况才看后缀,成功任务的 .txt 走 expires_at 有效期判断,口径与 is_export_download_available() 一致;成功任务残留的 .part 已经没有读者,按原意删除。
  • 测试: 在 tests/api/test_chapter_exports.py 新增两个用例,覆盖成功任务的两侧边界:有效期内保留 .txt 并删掉残留的 .part;过期后删除 .txt。

关联 Issue / PR

无

变更类型

  • 新功能 (feat)
  • 错误修复 (fix)
  • 文档更新 (docs)
  • 代码格式调整 (style)
  • 代码重构 (refactor)
  • 性能优化 (perf)
  • 测试相关 (test)
  • 杂项 (chore)

问题原因

if 与 elif 的条件顺序按"先后缀、后状态"写,而后缀在进入循环体前已经被 _job_id_from_export_path() 过滤成只剩 .part 和 .txt 两种,第一个条件因此恒真,把第二个分支完全遮蔽。两个分支想表达的是两件不同的事:.part 是仍在写入的临时文件,只在任务进行中有意义;.txt 是成品,成功后要按 expires_at 留满 24 小时。按状态先分流之后,两件事各自成立。

自查清单

  • 已添加/更新必要的测试
  • 已运行 lint 和 typecheck,无报错
  • 已运行测试用例,全部通过
  • 变更范围合理,未包含无关修改
  • 未暴露敏感信息(API Key、密码等)
  • 提交信息符合规范

补充信息

复现与验证都在本地跑过(macOS 15,Python 3.12,uv sync --frozen)。复现用一次性用例串起完整链路:建项目和章节、创建导出任务、dispatch_job 写完成品、mark_succeeded,然后调一次 cleanup_chapter_export_files() 再请求下载接口。在未修改的 main 上,清理前下载返回 200,清理删掉 1 个文件,清理后下载返回 409 {"detail":"导出文件不可用或已过期"};带本 PR 的改动则是清理删除 0 个文件,清理后下载仍为 200。该一次性用例只用于复现,没有提交。

CI 对应的命令结果:uv run ruff check . 全部通过,uv run ty check app 全部通过,uv run pytest 1717 passed(60.47s),uv build 两个产物都构建成功。单独跑改动涉及的文件:uv run pytest tests/api/test_chapter_exports.py 13 passed;新增的第一个用例在未修改的 main 上确实失败(assert 2 == 1,即成品和残留临时文件被一起删掉)。

cleanup_chapter_export_files 里的分支条件写反了顺序:先判断
`path.suffix in {".part", ".txt"}`,再在 elif 里判断
`path.suffix == ".txt" and job.status == JOB_STATUS_SUCCEEDED`。
_job_id_from_export_path 已经保证后缀只会是 .part 或 .txt,所以第一个
条件恒为真,带有效期判断的 elif 分支永远不会执行。任务成功后状态不再
属于 pending/running/cancel_requested,成品 .txt 于是在下一次清理时
被直接删除。

清理由后台 watchdog 每 background_running_stale_seconds/2(默认 300 秒)
触发一次,也在应用启动维护阶段跑一次,因此导出完成后最多五分钟,
EXPORT_FILE_TTL 声称的 24 小时下载窗口就失效:
is_export_download_available 因为 output_path.is_file() 为假而返回 False,
前端的 download_url 消失,直接请求下载接口返回 409。

改为先按任务状态判断是否仍在进行(进行中 .part 与 .txt 都保留,成品是
os.replace 落盘的,可能早于状态提交),再对成功任务的 .txt 走有效期判断。
成功任务残留的 .part 不再有读者,按原意删除。

新增两个用例覆盖成功任务:有效期内保留 .txt 并删除残留 .part,过期后
删除 .txt。
@syrizelink
syrizelink merged commit 5d020cc into syrizelink:main Oct 6, 2026
5 checks passed
@syrizelink

Copy link
Copy Markdown
Owner

Thank you for your work.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants