Archived
refactor: 两个 ZIP 打包 worker 合并为一个参数化引擎(604 行重复骨架 → 一份)
为什么:_batch_zip_worker(327 行 / 圈 78 / 嵌套 9)与 _podcast_zip_worker
(277 行 / 圈 84 / 嵌套 9)是两份平行实现——任务状态机、断线暂停、全局批量
名额、「逐条失败不计整批」、取消与异常的四路终态收敛、账本结算全部各写一遍,
连注释都逐字重复。上一轮修的 zip_valid 未绑定缺陷正是同型出现在这两处,
重复代码在持续制造同类 bug。
改法:新增 services/_zipcore.py 只保留共用骨架,差异收敛到 ZipJob 钩子:
prepare / download_one / write_entry / zip_filename 必实现,prefetch /
fail_label / done_msg / log_label / all_failed_msg / partial_msg / supplement /
on_delivered / cleanup 按需覆写。batch.py 与 podcast.py 只剩薄包装 + flavor 类。
线程模型(改动中最容易踩坏的一条,已写进模块 docstring):
ZipFile 不是线程安全的,且包内名去重是「读改写 used_arcnames」。因此把下载与
落包拆成两个钩子——只有 download_one 进线程池,zf.write 与去重一律在调用线程
串行。旧的音乐批量之所以安全,只是因为它的 zf.write 恰好写在主线程的
as_completed 循环里;照搬成「整个下载单元进池」会引入静默的 ZIP 损坏。
复杂度(同口径 AST 实测):
两个 worker 合计 604 行 / 圈 162 / 嵌套 9,9
→ run_zip_job 167 行 / 圈 37 / 嵌套 4
+ _fill_zip 64 行 / 圈 13 / 嵌套 5
全仓最大圈复杂度 84 → 37;>80 行函数 3 → 2(余下两个之一是本次未改动的
_podcast_download_worker)。两文件改为三文件后总行数 930 → 1071,多出的
主要是引擎 docstring——注释比代码贵,但它把「为什么这么拆」留给了下一个人。
逐条对齐的分叉(合并必须做的决定,此处全部列明):
1. 账本终态:播客全失败走自己的 except DownloadException → 落 'settled',音乐
同语义事件落 'error'。统一为 'error'。全仓仅读取 'open'(对账/清理/计数),
其余取值只作排障留痕 → 这是本次**唯一可观察的语义变更**,由新增用例固化。
2. 通用异常路径的「部分交付」:音乐侧会发布已打包的 ZIP 并挂 result,播客侧
直接丢弃(却已按 delivered 结算 = 用户扣了费拿不到货)。统一采用音乐侧口径。
该路径实际不可达(ZIP 写完后到发布前只有取消检查会抛错,已被取消分支接管)。
3. 全失败判定:音乐用 `ok == 0 and failed`、播客用早抛 DownloadException。
统一为显式 `ok == 0`——好处是不再发布空 ZIP、不再把空批次标 done。
4. 早抛业务失败(无可下载条目 / 补扣失败):播客走 DownloadException 分支,
音乐无此分支(落通用异常,用户看到的是「打包失败,请稍后重试」)。统一保留
该分支,音乐侧同类失败从此也给出具体原因。
5. 错误终态写法:播客在手写任务字典(重复一遍「已终态不覆盖」,且漏掉 /F-E 的
「worker 线程已死则按账本补结算」兜底),统一走 _mark_task_terminal。
6. 暂停检查点:统一采用音乐侧更严的一处——拿到 BATCH_ZIP_SEM 后再查一次
pause/cancel。播客此前会多下完一集才停。
7. total 字段:音乐由提交侧设置、播客由 worker 设置,统一为 worker 设
len(items)(对音乐是同一个值)。
8. 日志文案统一(tag / 名词提成类属性):'批量打包异常' 与 '批量ZIP任务异常'
两种前缀收敛为一种;完成日志统一带上 task_id 与退回次数。
删除的不可达分支(可证明):
两个 worker 在 zip_valid = True 之后各留了一段 `if not zip_valid:` 失败收敛
(音乐 16 行 / 播客 18 行,含「ZIP 打包失败(可能磁盘空间不足)…」文案)。
赋值紧跟其后、中间无任何语句可改写 zip_valid,故恒不可达——是原注释所称
「复审 修正」留下的护栏。zip_valid 在异常路径仍有意义(决定 delivered 是否为 0)。
等价性证据(三道,互相独立):
1. AST 字面量多重集比对 + 逐条位置定位:数值 16 种一个不少。少掉的 8 处 `0`
全部来自上面删的不可达分支与重复字面量合并(`delivered = … else 0` 5 处→3 处)。
2. 反向注入验证:把 git HEAD 的旧 worker 注入成 services.batch / services.podcast,
用同一套 tests/test_zip_workers.py 去跑 → 第 1 批 9 例行为契约**全过**,证明
这套测试忠实编码了旧行为、因而对新实现构成等价性验证;新增 4 例中只有账本
终态那 1 例失败('settled' == 'error'),恰好把上面第 1 条对齐实证出来。该用例
里「全部单集下载失败: …(已退回 2 次下载次数)」的文案断言在旧实现上也通过
→ 用户可见文案逐字保留。
3. 新增 4 例固化测试:同名歌声的音频/歌词/封面三条目共享序号后缀(F-C)、
两边的全失败文案与账本终态、单集失败后任务目录不留中间文件与 .part 残片。
顺带修掉一个自己引入的缺陷:引擎化后播客侧的单条失败文案本会退化成整个条目
字典,字面量审计里 '(' ')' 各少 1 处把它逼了出来 → 补 ZipJob.fail_label 覆盖。
验证:ruff 全过 · pyright 0/0 · pytest 213 passed(209→213)· 生产 data/ 未被
触碰(全部用例走 tmp_path 沙箱)。
This commit is contained in:
1 parent
65a81af94a
commit
5c8f941d19
4 files changed
+848
-579
No files matched your search
+123
-236
@@ -1,13 +1,17 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""播客单集与整台打包 worker。
|
||||
"""
|
||||
|
||||
整台打包的骨架(状态机 / 暂停 / 名额 / 终态收敛 / 结算)在
|
||||
:mod:`services._zipcore`,本模块只保留播客侧策略:清单组装(整台或指定单集)、
|
||||
逐集顺序下载、按实际集数补扣、单条目入包(首集附封面)。
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -27,14 +31,16 @@ from music_downloader import (
|
||||
)
|
||||
|
||||
from ._shared import (
|
||||
BATCH_ZIP_SEM,
|
||||
_record_log,
|
||||
)
|
||||
from ._taskcore import (
|
||||
_mark_task_terminal,
|
||||
_settle_batch_billing,
|
||||
_task_work_dir,
|
||||
_wait_if_paused,
|
||||
)
|
||||
from ._zipcore import (
|
||||
ZipContext,
|
||||
ZipJob,
|
||||
run_zip_job,
|
||||
)
|
||||
from .api_service import (
|
||||
api_service,
|
||||
@@ -287,32 +293,37 @@ def _podcast_cleanup_files(paths: list[str | None]) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def _podcast_zip_worker(task_id: str) -> None:
|
||||
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
|
||||
import tempfile as _tempfile
|
||||
@dataclass(frozen=True)
|
||||
class _EpisodePayload:
|
||||
"""一集下载完成后的入包素材(下载线程产出,落包线程消费)。"""
|
||||
|
||||
zip_tmp_path: str | None = None
|
||||
ok_count = 0
|
||||
failed: list[str] = []
|
||||
# zip_valid / radio_name 必须在函数头初始化——空电台/无单集/补扣失败等
|
||||
# 正常业务错误在 try 内早期 raise,异常处理器引用未绑定名会二次崩溃 worker 线程
|
||||
zip_valid = False
|
||||
radio_name = ''
|
||||
try:
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
task = DOWNLOAD_TASKS[task_id]
|
||||
task['status'] = 'running'
|
||||
task['msg'] = '解析单集列表...'
|
||||
params = task['params']
|
||||
cancel_ev = task['cancel']
|
||||
pause_ev = task['pause']
|
||||
radio_id = params['radio_id']
|
||||
preset_ids = params.get('ids')
|
||||
limit = params['limit']
|
||||
quality = params['quality']
|
||||
path: str
|
||||
base: str
|
||||
ext: str
|
||||
|
||||
|
||||
class _PodcastZipJob(ZipJob):
|
||||
"""整台播客打包:逐集顺序下载(窗口=1)→ 单条目入包,首集附封面。
|
||||
|
||||
并发刻意等于音乐批量:同样占用全局批量名额(BATCH_ZIP_SEM,全局 3),
|
||||
逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受全局
|
||||
下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发。
|
||||
"""
|
||||
|
||||
tag = '播客打包'
|
||||
noun = '集'
|
||||
item_noun = '单集'
|
||||
tmp_prefix = 'podcast_batch_'
|
||||
start_msg = '解析单集列表...'
|
||||
concurrency = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._radio_name = ''
|
||||
|
||||
def prepare(self, params: dict[str, Any], task_id: str, failed: list[str]) -> list[Any]:
|
||||
cookies = params['cookies']
|
||||
remote_ip = params.get('remote_ip') or '-'
|
||||
|
||||
radio_id = params['radio_id']
|
||||
limit = params['limit']
|
||||
# 组装单集清单:整台电台(正序全量)或指定单集ID列表
|
||||
if radio_id:
|
||||
episodes = podcast.list_radio_all_program_ids(radio_id, cookies, max_count=limit)
|
||||
@@ -320,7 +331,7 @@ def _podcast_zip_worker(task_id: str) -> None:
|
||||
raise DownloadException("该电台暂无可下载的单集")
|
||||
else:
|
||||
episodes = []
|
||||
for pid in (preset_ids or []):
|
||||
for pid in (params.get('ids') or []):
|
||||
try:
|
||||
prog = podcast.get_program_detail(pid, cookies)
|
||||
episodes.append({
|
||||
@@ -337,15 +348,17 @@ def _podcast_zip_worker(task_id: str) -> None:
|
||||
if not episodes:
|
||||
raise DownloadException("没有可下载的单集")
|
||||
|
||||
radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客')
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
DOWNLOAD_TASKS[task_id]['total'] = len(episodes)
|
||||
self._radio_name = _podcast_safe_name(params.get('radio_name') or '', '播客')
|
||||
return episodes
|
||||
|
||||
# 按次计费补扣:整台电台提交时只预扣 1 次,此处按实际单集数补足差额。
|
||||
# 补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付
|
||||
def supplement(self, task_id: str, params: dict[str, Any], count: int) -> None:
|
||||
"""按次计费补扣:整台电台提交时只预扣 1 次,此处按实际单集数补足差额。
|
||||
|
||||
补扣失败(余额不足/数据库异常)任务必须失败并按账本退费——不允许无账本继续交付。
|
||||
"""
|
||||
try:
|
||||
_buid = params.get('billing_uid')
|
||||
_extra = len(episodes) - int(params.get('billed') or 1)
|
||||
_extra = count - int(params.get('billed') or 1)
|
||||
if _buid and _extra > 0:
|
||||
if not auth_system.ledger_supplement(task_id, _extra):
|
||||
raise DownloadException(
|
||||
@@ -353,7 +366,7 @@ def _podcast_zip_worker(task_id: str) -> None:
|
||||
if _buid:
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
DOWNLOAD_TASKS[task_id]['billing_user_id'] = int(_buid)
|
||||
DOWNLOAD_TASKS[task_id]['billing_cost'] = len(episodes)
|
||||
DOWNLOAD_TASKS[task_id]['billing_cost'] = count
|
||||
except DownloadCanceled:
|
||||
raise
|
||||
except DownloadException:
|
||||
@@ -361,206 +374,80 @@ def _podcast_zip_worker(task_id: str) -> None:
|
||||
except Exception as e:
|
||||
raise DownloadException(f"补扣下载次数异常: {e}") from e
|
||||
|
||||
def _update_progress(msg: str = '') -> None:
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS[task_id]
|
||||
t['done'] = ok_count + len(failed)
|
||||
t['failed'] = len(failed)
|
||||
if msg:
|
||||
t['msg'] = msg
|
||||
|
||||
# 对齐音乐批量 F-B 口径——zip_valid 仅在 ZIP with 块正常
|
||||
# 退出(中央目录完整写出)后才置 True(初始 False 已上移函数头);
|
||||
# 未 valid 一律不发布、不按 ok_count 结算
|
||||
# 同音乐批量——ZIP 构建放 downloads 卷,不落系统 %TEMP%
|
||||
# 与音乐批量打包同口径:建在任务专属目录 tasks/<task_id>/,根目录缓存清扫不再触及交付物
|
||||
_podcast_task_dir = _task_work_dir(task_id)
|
||||
with _tempfile.NamedTemporaryFile(prefix='podcast_batch_', suffix='.zip',
|
||||
dir=str(_podcast_task_dir),
|
||||
delete=False) as zip_tmp:
|
||||
zip_tmp_path = zip_tmp.name
|
||||
used_arcnames = set() # 包内条目名去重(同名单集/同号不同集不得互相覆盖)
|
||||
with zipfile.ZipFile(zip_tmp, 'w', zipfile.ZIP_DEFLATED) as zf:
|
||||
for idx, ep in enumerate(episodes, 1):
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
_wait_if_paused(task, pause_ev, cancel_ev, ok_count + len(failed))
|
||||
# 并发对齐音乐批量:播客打包同样占用全局批量名额(BATCH_ZIP_SEM,全局 3),
|
||||
# 逐集 acquire/release——暂停等待/取消时不长期占名额;音频本身仍受
|
||||
# 全局下载信号量约束。原实现不受批量并发控制,多用户同时打包会无限并发
|
||||
if not BATCH_ZIP_SEM.acquire_cancellable(cancel_ev):
|
||||
raise DownloadCanceled("任务已取消")
|
||||
try:
|
||||
label = f"[{idx}/{len(episodes)}] {ep['name'][:24]}"
|
||||
_update_progress(f"{label} 解析中...")
|
||||
try:
|
||||
prog = ep.get('detail') or podcast.get_program_detail(ep['id'], cookies)
|
||||
url_info = _podcast_resolve_audio(prog, quality, cookies)
|
||||
_update_progress(f"{label} 下载中...")
|
||||
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
|
||||
serial = int(ep.get('serial_num') or 0)
|
||||
prefix = f"{serial:03d} - " if serial else ''
|
||||
base = _podcast_safe_name(f"{prefix}{ep['name']}")
|
||||
# 中间文件移入任务目录——落 downloads 根目录会被
|
||||
# 缓存清扫按 mtime 打断慢速单集(Linux 上 unlink 打开中的文件)
|
||||
tmp_audio = _podcast_task_dir / f"podcast_zip_tmp_{task_id}_{idx}.{ext}"
|
||||
try:
|
||||
_podcast_stream_download(url_info['url'], tmp_audio,
|
||||
url_info.get('size', 0), cancel_ev)
|
||||
# 重名条目自动追加序号
|
||||
arcname = f"{base}.{ext}"
|
||||
k = 2
|
||||
while arcname in used_arcnames:
|
||||
arcname = f"{base} ({k}).{ext}"
|
||||
k += 1
|
||||
used_arcnames.add(arcname)
|
||||
zf.write(str(tmp_audio), arcname=arcname, compress_type=zipfile.ZIP_STORED)
|
||||
finally:
|
||||
# finally 连带清理 .part 残片(原实现只删正文,
|
||||
# 取消/失败时残片要等 1 小时缓存清扫兜底)
|
||||
_podcast_cleanup_files([str(tmp_audio)])
|
||||
# 首集附带封面,便于识别
|
||||
if idx == 1 and ep.get('cover_url'):
|
||||
try:
|
||||
cover = fetch_cover_bytes(ep['cover_url'])
|
||||
if cover:
|
||||
zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED)
|
||||
except Exception:
|
||||
pass
|
||||
ok_count += 1
|
||||
_update_progress(f"{label} 完成")
|
||||
except DownloadCanceled:
|
||||
raise
|
||||
except Exception as e:
|
||||
failed.append(f"{ep['name']}({e})")
|
||||
_update_progress()
|
||||
finally:
|
||||
BATCH_ZIP_SEM.release()
|
||||
|
||||
# 走到这里说明 ZIP with 块正常退出、中央目录完整,才允许发布
|
||||
zip_valid = True
|
||||
|
||||
if ok_count == 0:
|
||||
raise DownloadException(f"全部单集下载失败: {'; '.join(failed[:3])}")
|
||||
|
||||
# ZIP 未正常完成(磁盘满写坏中央目录等)——不发布坏包,按全额退费收敛
|
||||
if not zip_valid:
|
||||
if zip_tmp_path:
|
||||
try:
|
||||
os.remove(zip_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
zip_tmp_path = None
|
||||
refunded = _settle_batch_billing(task_id, 0, 'error')
|
||||
_mark_task_terminal(task_id, 'error',
|
||||
f"ZIP 打包失败(可能磁盘空间不足),已退回 {refunded} 次" if refunded
|
||||
else "ZIP 打包失败(可能磁盘空间不足),费用未扣或已退回")
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS.get(task_id)
|
||||
if t is not None:
|
||||
t['failed'] = t.get('failed', 0) + ok_count
|
||||
t['refunded'] = refunded
|
||||
api_service.logger.error(f"播客打包构建失败未发布: {task_id} ok_count={ok_count} 退回{refunded}次")
|
||||
return
|
||||
|
||||
# 最终发布前的取消检查(末段取消不得覆盖为 done)
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
refunded = _settle_batch_billing(task_id, ok_count)
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS.get(task_id)
|
||||
# 发布前锁内复查取消/失效状态(同音乐批量)
|
||||
if t is None or t.get('status') in ('canceled', 'error'):
|
||||
raise DownloadCanceled("任务已取消")
|
||||
if cancel_ev.is_set():
|
||||
raise DownloadCanceled("任务已取消")
|
||||
t['status'] = 'done'
|
||||
t['completed_at'] = time.time()
|
||||
t['done'] = ok_count + len(failed)
|
||||
t['failed'] = len(failed)
|
||||
t['msg'] = f"成功{ok_count} 失败{len(failed)}" + (f" 退回{refunded}次" if refunded else '')
|
||||
t['refunded'] = refunded
|
||||
t['result'] = {
|
||||
'zip_path': zip_tmp_path,
|
||||
'filename': f"播客_{radio_name}_{ok_count}集.zip",
|
||||
'audio_files': [],
|
||||
}
|
||||
def download_one(self, item: Any, idx: int, ctx: ZipContext) -> _EpisodePayload:
|
||||
ep = item
|
||||
label = f"[{idx}/{ctx.total}] {ep['name'][:24]}"
|
||||
ctx.progress(f"{label} 解析中...")
|
||||
cookies = ctx.params['cookies']
|
||||
prog = ep.get('detail') or podcast.get_program_detail(ep['id'], cookies)
|
||||
url_info = _podcast_resolve_audio(prog, ctx.params['quality'], cookies)
|
||||
ctx.progress(f"{label} 下载中...")
|
||||
ext = (url_info.get('type') or 'mp3').lower().lstrip('.')
|
||||
serial = int(ep.get('serial_num') or 0)
|
||||
prefix = f"{serial:03d} - " if serial else ''
|
||||
base = _podcast_safe_name(f"{prefix}{ep['name']}")
|
||||
# 中间文件移入任务目录——落 downloads 根目录会被缓存清扫按 mtime 打断慢速
|
||||
# 单集(Linux 上 unlink 打开中的文件)
|
||||
tmp_audio = ctx.work_dir / f"podcast_zip_tmp_{ctx.task_id}_{idx}.{ext}"
|
||||
try:
|
||||
first_name = episodes[0].get('name') if episodes else ''
|
||||
_record_log('下载', remote_ip, f"[播客打包] {radio_name} · {first_name}")
|
||||
_podcast_stream_download(url_info['url'], tmp_audio,
|
||||
url_info.get('size', 0), ctx.cancel_ev)
|
||||
except Exception:
|
||||
# 下载失败即清(.part 残片同清);成功时由 write_entry 的 finally 清
|
||||
_podcast_cleanup_files([str(tmp_audio)])
|
||||
raise
|
||||
return _EpisodePayload(path=str(tmp_audio), base=base, ext=ext)
|
||||
|
||||
def write_entry(self, item: Any, idx: int, payload: _EpisodePayload, ctx: ZipContext) -> None:
|
||||
ep = item
|
||||
try:
|
||||
# 重名条目自动追加序号(同名单集/同号不同集不得互相覆盖)
|
||||
arcname = f"{payload.base}.{payload.ext}"
|
||||
k = 2
|
||||
while arcname in ctx.used_arcnames:
|
||||
arcname = f"{payload.base} ({k}).{payload.ext}"
|
||||
k += 1
|
||||
ctx.used_arcnames.add(arcname)
|
||||
ctx.zf.write(payload.path, arcname=arcname, compress_type=zipfile.ZIP_STORED)
|
||||
# 首集附带封面,便于识别
|
||||
if idx == 1 and ep.get('cover_url'):
|
||||
try:
|
||||
cover = fetch_cover_bytes(ep['cover_url'])
|
||||
if cover:
|
||||
ctx.zf.writestr("cover.jpg", cover, compress_type=zipfile.ZIP_STORED)
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# finally 连带清理 .part 残片(原实现只删正文,取消/失败时残片要等
|
||||
# 1 小时缓存清扫兜底)
|
||||
_podcast_cleanup_files([payload.path])
|
||||
|
||||
def fail_label(self, item: Any) -> str:
|
||||
"""失败文案用单集名而非整个条目字典(引擎拼成「单集名(异常)」)。"""
|
||||
return str(item.get('name') or '')
|
||||
|
||||
def done_msg(self, item: Any, idx: int, ctx: ZipContext) -> str:
|
||||
return f"[{idx}/{ctx.total}] {item['name'][:24]} 完成"
|
||||
|
||||
def zip_filename(self, ok_count: int) -> str:
|
||||
return f"播客_{self._radio_name or '打包'}_{ok_count}集.zip"
|
||||
|
||||
def log_label(self) -> str:
|
||||
return f"{self._radio_name} "
|
||||
|
||||
def all_failed_msg(self, failed: list[str], refunded: int) -> str:
|
||||
return f"全部单集下载失败: {'; '.join(failed[:3])}" + (
|
||||
f"(已退回 {refunded} 次下载次数)" if refunded else '')
|
||||
|
||||
def on_delivered(self, items: list[Any], task_id: str, params: dict[str, Any]) -> None:
|
||||
try:
|
||||
first_name = items[0].get('name') if items else ''
|
||||
_record_log('下载', params.get('remote_ip') or '-',
|
||||
f"[播客打包] {self._radio_name} · {first_name}")
|
||||
except Exception:
|
||||
pass
|
||||
api_service.logger.info(f"播客打包完成: {radio_name} 成功{ok_count} 失败{len(failed)} ({task_id})")
|
||||
except DownloadCanceled:
|
||||
# 部分成功后取消——已入包部分照常交付,未完成部分按账本退费
|
||||
# ZIP 未正常完成(写坏)时不按 ok_count 交付——按全额退费收敛
|
||||
delivered = ok_count if zip_valid else 0
|
||||
refunded = _settle_batch_billing(task_id, delivered, 'canceled')
|
||||
if delivered > 0 and zip_tmp_path:
|
||||
_mark_task_terminal(task_id, 'canceled',
|
||||
f"已取消(已打包 {ok_count} 集可领取)" +
|
||||
(f",退回 {refunded} 次" if refunded else ''))
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS.get(task_id)
|
||||
if t is not None:
|
||||
t['refunded'] = refunded
|
||||
t['result'] = {
|
||||
'zip_path': zip_tmp_path,
|
||||
'filename': f"播客_{radio_name or '打包'}_{ok_count}集.zip",
|
||||
'audio_files': [],
|
||||
}
|
||||
else:
|
||||
_mark_task_terminal(task_id, 'canceled',
|
||||
'任务已取消' + (f"(已退回 {refunded} 次下载次数)" if refunded else ''))
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
t = DOWNLOAD_TASKS.get(task_id)
|
||||
if t is not None:
|
||||
t['refunded'] = refunded
|
||||
if zip_tmp_path:
|
||||
try:
|
||||
os.remove(zip_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
api_service.logger.info(f"播客打包已取消: {task_id}(成功{ok_count} 退回{refunded}次)")
|
||||
except DownloadException as e:
|
||||
delivered = ok_count if zip_valid else 0
|
||||
refunded = _settle_batch_billing(task_id, delivered)
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
_t = DOWNLOAD_TASKS.get(task_id)
|
||||
# 终态先到先得——已终态(如用户取消/超时兜底)
|
||||
# 不再被覆盖为 error(堵 canceled→error 非法迁移边)
|
||||
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
|
||||
_t['status'] = 'error'
|
||||
_t['completed_at'] = time.time() # 保留期从完成时刻起算
|
||||
_t['msg'] = str(e) + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
|
||||
_t['refunded'] = refunded
|
||||
elif _t:
|
||||
_t['refunded'] = refunded
|
||||
if zip_tmp_path:
|
||||
try:
|
||||
os.remove(zip_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
api_service.logger.error(f"播客打包失败: {task_id} {e}(退回{refunded}次)")
|
||||
except Exception as e:
|
||||
# 非预期异常原文 logger 先行,用户可见文案泛化
|
||||
# (DownloadException 的文案是我们自己拼的用户可读文本,保留 str(e))
|
||||
api_service.logger.error(f"播客打包异常: {task_id} {e}\n{traceback.format_exc()}")
|
||||
delivered = ok_count if zip_valid else 0
|
||||
refunded = _settle_batch_billing(task_id, delivered)
|
||||
with DOWNLOAD_TASKS_LOCK:
|
||||
_t = DOWNLOAD_TASKS.get(task_id)
|
||||
# 同上——已终态不再覆盖为 error
|
||||
if _t and _t.get('status') not in ('done', 'error', 'canceled'):
|
||||
_t['status'] = 'error'
|
||||
_t['completed_at'] = time.time() # 保留期从完成时刻起算
|
||||
_t['msg'] = '打包失败,请稍后重试' + (f"(已退回 {refunded} 次下载次数)" if refunded else '')
|
||||
_t['refunded'] = refunded
|
||||
elif _t:
|
||||
_t['refunded'] = refunded
|
||||
if zip_tmp_path:
|
||||
try:
|
||||
os.remove(zip_tmp_path)
|
||||
except OSError:
|
||||
pass
|
||||
api_service.logger.error(f"播客打包异常: {task_id} {e}(退回{refunded}次)\n{traceback.format_exc()}")
|
||||
|
||||
|
||||
def _podcast_zip_worker(task_id: str) -> None:
|
||||
"""整台播客打包:逐集 下载→入包即删,支持取消与断线暂停(复用批量任务机制)"""
|
||||
run_zip_job(_PodcastZipJob(), task_id, settle=_settle_batch_billing)
|
||||
Reference in new issue
Block a user