Commit Graph
18 Commits
Author SHA1 Message Date
snowy-yang 69a50c2c31 refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。
worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给
另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成
协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。

改动(按子系统):
- music_downloader:downloader / _aria2 / _concurrency / cover / _http /
  __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改
  「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让
  已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经
  to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式
  读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已
  有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。
  删除 get_http_session / _SESSION_LOCAL。
  models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception:
  它是「续传起点失效、残片已清、从头重下」,既不是确定性失败
  (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立
  成类且在 except 链中先于二者捕获。
- music_api:podcast.py 全部函数协程化(直链解析内部改
  await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已
  清空,文件只剩空壳。
- services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。
  threading.Thread → loop.create_task(任务句柄字段 worker_thread →
  worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore,
  终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。
  删除 services/streaming.py(同步流垫片,唯一消费者已迁到
  core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、
  「入包」严格串行。
- web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是
  把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。
  web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。
  web/routers/download.py 的播客端点补上 3 处漏掉的 await
  (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象,
  靠 pyright 抓出来后修正。

留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、
mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、
PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/
往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject
逐条注明理由。

取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker
协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件
循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。

测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的
get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks
(顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...));
aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成
done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在
常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。

下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾;
B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅
sync_http 自用)。
2026-09-28 19:36:56 +08:00
snowy-yang b089dadac2 refactor: geo 走原生异步 HTTP,清掉短链展开死链
「同步转异步」的残留清理:

1) auth_system/geo.query_ip_province 原经 core.sync_http 同步门面(在后台
   事件循环桥接)查询归属地,但其唯一调用方 record_login_location 的入口
   本来就是 async 的登录链路(web/routers/auth 里还套了一层 to_thread)。
   改为直接 await core.http_client——桥接纯属多余,顺带把 record_login_location
   转 async,落库仍走同步 sqlite 但经 asyncio.to_thread 卸载,避免 sqlite busy
   等待(最长 15s)冻结事件循环。
   调用方两处(普通用户/管理员登录)去掉 to_thread 包装,直接 await。

2) MusicAPIService._extract_music_id 是死代码:ID 归一化早已迁到
   web.request_utils(注释里明说是它的迁移口径),全仓零调用。它也是
   core.utils.expand_shortlink 的唯一调用方——一并删除,core.utils 由此
   彻底不再 import core.sync_http。

门禁:ruff 全过 / pyright 0-0 / pytest 387 项全绿(0 失败)。
2026-09-28 19:10:45 +08:00
snowy-yang 1c6b17f718 refactor: 播客单集 worker 拆解(140 行 / 圈 35 → 40 行 / 圈 6 / 嵌套 1)
为什么:_podcast_download_worker 是播客单集下载的唯一后台执行路径,
此前零直接测试覆盖;三条 except 分支各写一遍「结算+删任务目录」,
正文散着三处取消检查,改坏任意一处不会有测试报警。

拆出(与单曲 worker 9cbd341 同风格):
- _PodcastTaskSpec(frozen dataclass,program_id 标注 Any——params 历史
  存数字字符串,worker 只透传,int() 是行为变更)
- _PodcastTarget(磁盘名/交付名/任务目录的命名组装)
- _begin_podcast_task / _prepare_podcast_target / _write_podcast_zip /
  _podcast_file_result / _podcast_zip_result / _publish_podcast_task /
  _fail_podcast_task(三处 ×9 行收尾收敛)
- 取消检查复用 downloads._abort_if_canceled(无循环依赖);tempfile
  局部 import 上移模块顶

先写测试后动代码:新增 tests/test_podcast_worker.py 18 例,在**未改动**
实现上一次跑绿 = 忠实编码旧行为,重构后同一套测试即等价性验证。
唯一语义顺序变化:except 分支的日志从「收尾后」挪到「收尾前」(与
单曲 worker 对齐,日志内容不变,无行为影响)。
门禁:ruff 全过 · pyright 0/0 · pytest 312 passed(294→312)。
2026-09-27 09:30:52 +08:00
snowy-yang da83201403 refactor: run_zip_job 四路终态收尾拆解(167 行 / 圈 37 → 32 行 / 圈 5)
为什么:run_zip_job 是全仓圈复杂度最高的函数(37),167 行里四个终态——正常交付、
全部失败、取消(含部分交付/全额退两条支路)、异常(同样两条支路)——各写一段
8~20 行的收尾内联在 try/except 里,且都伸手去读同一批局部变量。上一轮抽这个引擎时
就已指出「还可以再拆」,当时为控制单次改动面留了下来。

改法:把「一次运行」的状态收进 _ZipRun,四个终态各自成函数。
  _ZipRun             job / task_id / settle / tally / task / params /
                      cancel_ev / pause_ev / items / zip_tmp_path / zip_valid / t0
  _progress_of        构造进度回调(原先是个内嵌闭包)
  _start_zip_run      置 running + 组装清单 + 写 total + 计费补扣
  _build_zip_archive  建临时 ZIP + 逐窗口填充 + 置 zip_valid + 记耗时
  _publish_delivery   正常交付(结算 → 锁内复查 → 发布 result → on_delivered → 日志)
  _finish_all_failed  全失败(丢弃空包 + 全额退费 + 转 error)
  _finish_canceled    取消(部分交付 / 全额退两条支路)
  _finish_business_error  DownloadException(保留我们自己拼的用户可读文案)
  _finish_unexpected  通用异常(先落 traceback 再泛化文案,两条支路)

复杂度(同口径 AST 实测,HEAD 与工作区由同一脚本测出):
  run_zip_job        167 行 / 圈 37 / 嵌套 4  →  32 行 / 圈 5 / 嵌套 2
  文件内最大圈复杂度   37(就是它)             →  13(_fill_zip,本次未动)
  文件行数 439 → 522(+83,全是新签名与 docstring)· 函数数 19 → 27

顺手消掉一个**隐患**,不是纯搬家:``zip_valid`` 原先必须手工在函数头初始化为
False(注释写明「漏了就 NameError 二次崩线程」——它正是上上轮那个 bug 的残留
护栏)。收进 dataclass 后该约束由构造函数承担,而 ``_ZipRun(...)`` 的默认值就是
假值,物理上写不出「忘记初始化」。

等价性证据(两道,互相独立):
1. 不靠注入脚本,直接把文件还原成 HEAD 版跑同一套测试(git stash push 单文件 →
   跑 → pop):tests/test_zip_workers.py + test_monitor_jobs.py 共 33 例**全过**。
   这批测试是上一轮用反向注入验证过「忠实编码旧行为」的,故本轮它们对新实现
   构成等价性验证。
2. AST 字面量多重集比对:字符串 56 种**一个不少**——没有任何用户可见文案或日志
   格式被动过。数值唯一差异是 `0.0: 0 → 1`,即 _ZipRun.t0 的字段默认值。

刻意保留的语义(是决定,不是疏漏):
- _publish_delivery 里「先 settle 再抛 DownloadCanceled」的顺序原样——抛出去后
  _finish_canceled 会**再 settle 一次**(delivered 口径不同),依赖的是 ledger_settle
  幂等;原实现同样如此,未动。
- 取消与异常两条支路里「部分交付」与「全额退」的判定条件(`delivered > 0 and
  zip_tmp_path`)逐字保留。
- _finish_all_failed 不走 _apply_error:它要额外记 failed 计数,口径与 error 支路
  不同(原实现即如此)。
- record_duration 仍在 zip_valid 置位之后、全失败判定之前(保持原顺序)。

验证:ruff 全过 · pyright 0/0 · pytest 294 passed(与改动前同数)· 生产 data/ 未被
触碰(全部用例走 tmp_path 沙箱)。
2026-09-27 09:08:52 +08:00
snowy-yang 9cbd341e2e refactor: 单曲下载 worker 拆解(177 行 / 圈 36 → 43 行 / 圈 8)
为什么:_single_download_worker 是单曲下载的**唯一**后台执行路径(/download 的
file 模式由 start_task 启动它),177 行 / 圈复杂度 36 / 嵌套 5,且此前**零测试
覆盖**——改坏任意一处都不会有测试报警。它的三个 except 分支各写一遍「置终态 +
退款 + 删任务目录」(9 行 × 3),正文里又散着四处逐字相同的取消检查。

改法:一步一个具名函数,主函数只留线性流程。
  SingleTaskParams      参数快照(frozen dataclass),免去每步重列长参数表
  _begin_single_task    置 running + 取参数
  _fetch_single_metadata 一次预取歌曲信息 + 算交付基础名
  _single_progress_cb   构造字节级进度回调(限频 0.4s)
  _abort_if_canceled    取消检查点(原三处逐字重复 → 一处)
  _single_file_result   不打包的交付描述
  _pack_single_zip      音频 + 歌词 + 封面 + info.txt 打成一包
  _publish_single_task  置 done(已终态不覆盖)+ 结算 + 记日志
  _fail_single_task     失败/取消统一收尾(原三处 × 9 行 → 一处)

复杂度(同口径 AST 实测,HEAD 与工作区由同一脚本测出):
  _single_download_worker  177 行 / 圈 36 / 嵌套 5  →  43 行 / 圈 8 / 嵌套 2
  文件最大圈复杂度           36(就是它)             →  9(_single_progress_cb,
                                                          节流回调本身的形状)
  文件行数 234 → 310(+76,全是新签名与 docstring)· 函数数 3 → 11

等价性证据(三道,互相独立):
1. 19 例行为契约测试**在未改动的原始实现上一次性全绿**(先写测试、后动代码;
   改完再跑仍全绿)——这批测试因此忠实编码了旧行为,构成等价性验证,而不是
   「照着新实现写的断言」。断言只依赖任务终态 / 磁盘文件 / ZIP 内容 / 账本行 /
   余额,不引用任何内部函数名,可跨后续重构复用。
2. 反向注入验证:把 HEAD 版注入回 services.downloads(**sys.modules 与父包属性
   两处都要挂**——否则 monkeypatch.resolve 走 getattr 父包拿到真模块),同一套
   19 例**全过**,与第 1 条相互独立地再次证明测试忠实。
3. AST 字面量多重集比对 + 逐条位置归因:字符串 69 种**一个不少**——没有一条
   用户可见文案被动过。数值差异全部归因:
     0    4→2:三处 ledger_settle(task_id, 0, ...) 收敛成一处(理由参数化)
     True 5→4:三处 rmtree(..., ignore_errors=True) 收敛成一处(另 +1 为新增
               dataclass 的 frozen=True)
     '任务已取消' 5→3:三处 raise DownloadCanceled 收敛进 _abort_if_canceled

刻意保留的语义(是决定,不是疏漏):
- task_dir_path 仍在 try 外初始化、失败收尾仍用它判空——「下载未开始就失败」
  时无目录可删,语义原样。
- 结算失败**不**把 done 改标 error(钱由重启对账兜底,宁可商家小损不坑用户),
  已用 test_settle_failure_does_not_flip_done_to_error 固化。
- 末段「已终态不得覆盖为 done」的状态检查与取消检查合流进 _publish_single_task,
  仍是先到先得;用 test_terminal_state_not_overwritten_by_late_success 固化。
- 日志调用移到各自 except 分支开头(原在收尾之后),仅调整同一分支内的顺序,
  不影响任何可观察行为;三处格式串原样保留。
- music_id 保持**原样透传**:params 里是数字字符串,而下游签名声明 int,两侧
  一直不一致、运行时靠字符串参与上游查询拼装而正常工作。顺手 int() 会把异常面
  从「上游查不到」变成 ValueError,属行为变更,故只把标注写成 Any 并在
  docstring 记明。

测试:新增 tests/test_single_worker.py(19 例)——正常交付与交付副本位置、
ZIP 四件套与 SRT 歌词、空歌词跳过、取封面失败仍发布、文件名格式、操作记录与
remote_ip 兜底、下载失败退费、未知异常泛化文案(上游细节不外泄)、下载中/下载
后/打包中三种取消、终态不被覆盖、进度回调三态(有总量 / 无总量 / 自身抛错)、
结算失败不影响已交付、worker 崩在结算前留给对账兜底。

顺带修正一处自己的假设错误:最初把「取封面失败」写成 fetch_cover_bytes 抛异常,
实测该函数契约是**失败返回 None**(内部已 except Exception),抛异常是不存在的
形状——改为注入 None 后才在旧实现上跑绿。测试要贴合生产的真实形状。

验证:ruff 全过 · pyright 0/0 · pytest 294 passed(275→294)· 生产 data/ 未被
触碰(全部用例走 tmp_path 沙箱)。
2026-09-27 09:03:05 +08:00
snowy-yang a05af4a880 refactor: 播客流式下载拆解(嵌套 10 层 → 1,三段职责各就其位)
为什么:_podcast_stream_download 是播客单集下载的**唯一落盘路径**(单集 worker
与整台打包的首集都走它),却零测试覆盖;而且它把三段互不相关的职责——全局名额
排队、流式写盘与进度上报、完整性校验与坏文件清理——叠在 try/with/with/for 加四层
if 里,最大嵌套 10 层,是全仓最深的一个。

改法:拆成四个具名函数,主函数只留编排。
  _stream_to_part    :拿响应 → 写 .part → 按 0.5s 节流上报进度,返回 (已写, 期望)
  _report_progress   :把一次进度写进任务表(含单集任务的 live_percent)
  _verify_downloaded :完整性判定 + 清掉坏文件(精确相等 / 非空两条口径)
  _remove_quietly    :删文件并吞掉 OSError(Windows 句柄未释放时 remove 会抛)

复杂度(同口径 AST 实测):
  _podcast_stream_download  63 行 / 圈 19 / 嵌套 10  →  21 行 / 圈 3 / 嵌套 1
  新增 _stream_to_part 25 行/圈 8/嵌套 4 · _report_progress 22/6/3 · _verify_downloaded 14/3/1
  文件内嵌套 ≥5 层 2 → 1(余下那个是本次未动的 _podcast_download_worker)

刻意保留的语义(是决定,不是疏漏):
- 取消时 **.part 留存不删**(改名不发生,下次同名下载直接覆盖)——已用测试固化。
  不「顺手」改成删除,那是行为变更。
- 进度 msg 的计算与「是否真有可取的任务」无关:task_id 为空时仍会格式化字符串再
  return。这是既有的无害浪费,一并保留。
- 完整性口径原样:已知期望大小要求**精确相等**(放宽到 95% 容差会交付截断的坏文件
  还照扣次数),期望未知则退化为非空校验。

等价性证据(两道):
1. 反向注入验证:把 HEAD 的旧实现换回去,用同一套 tests/test_podcast_stream.py 跑
   → 12 例**全过**,证明这批测试忠实编码旧行为、对新实现构成等价性验证。
2. 新增 12 例行为契约(该函数此前零覆盖):正常落盘与改名、空分块跳过、
   Content-Length 兜底、字节数不符清坏文件、空下载报错、已知大小不容忍部分交付、
   上游 4xx、开始前取消、中途取消留 .part、running 任务写 msg/live_percent、
   终态任务不被覆盖、无 task_id 不报错。

顺带记录一个测试自身的坑:替身响应最初用 list(chunks) 归一化,导致传入生成器的
副作用(中途置取消位)在**构造时刻**就执行,测出来变成「开始前取消」——两条本不
相同的路径被混成一条。已改为保留原 iterable 并写明原因。

验证:ruff 全过 · pyright 0/0 · pytest 245 passed(233→245)· 生产 data/ 未被触碰。
2026-09-27 07:45:49 +08:00
snowy-yang 5c8f941d19 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 沙箱)。
2026-09-26 19:25:06 +08:00
snowy-yang 65a81af94a refactor: 拆分任务监控循环的 7 组周期作业(170 行 / 圈 58 → 17 行 / 圈 3)
为什么:_task_monitor_loop 是一个 170 行 / 圈复杂度 58 / 嵌套 8 层的单函数,把
7 组互不相关的周期性作业塞在一起——终态回收、超时取消、缓存清扫、限流桶清理、
账本对账、孤儿目录补扫、心跳离线——整轮共用一个 try/except。任何一处改动都要
通读全部 7 组逻辑;上一轮恢复它的启动点时,也正是这个体量让判读很吃力。

改法:一组作业一个具名函数,另抽 _run_monitor_tick 只负责编排顺序(循环本身
只剩「sleep + try + 限频记录」三件事)。

复杂度(同口径 AST 实测,重构前后):
  _task_monitor_loop   170 行 / 圈 58 / 嵌套 8  →  17 行 / 圈 3 / 嵌套 2
  文件内最大函数        170 行                  →  66 行(_reap_finished_tasks)
  文件内 >80 行函数      1 → 0  ·  嵌套 ≥5 层   1 → 0
  代价:总行数 231 → 291(+60,全部是新的函数签名与 docstring)

等价性证据(重构的分内之事,不是附赠):
  1. AST **数值字面量多重集**比对:旧 16 种 / 新 16 种,计数零差异——确认搬运
     中没有改任何阈值(4h 硬超时 / 8 次重试 / 60s / 24h / 5h / 90 天 / 600s …)。
  2. 剔除 docstring 后的**字符串字面量**比对:旧版有的一处不少——确认用户可见
     状态文案与日志格式没被抄错。
  3. 新增 20 例行为契约测试(tests/test_monitor_jobs.py),按「调用后世界里发生
     了什么」断言,不引用实现细节,故可跨后续重构复用。

刻意保留的语义(是决定,不是疏漏):
  - 单个 tick 仍共用一个 try/except:前段作业抛异常会跳过本轮剩余作业。改为
    逐作业隔离属于**行为变更**,本提交不夹带;已用 test_tick_exception_skips_
    rest_of_tick 如实钉住现状,让那次变更有据可依、不会悄悄发生。
  - _run_periodic_ledger_jobs / _sweep_orphan_task_dirs 的局部 try/except 原样
    保留(原注释即写明「下轮重试」意图),其兜底粒度各有独立用例。
  - 代结算的前提仍是「worker_thread 字段存在且线程已死」:没有线程句柄的任务
    不退款——拿不到句柄就无法判断 worker 是否已在别处结算过,贸然退会双退。
    该约束由新增用例固化(原实现如此,此前无测试记录)。

验证:ruff 全过 · pyright 0/0 · pytest 209 passed(189→209)·
生产 data/ 未被触碰(全部用例走 tmp_path 沙箱)。
2026-09-26 19:04:36 +08:00
snowy-yang 0569b50737 refactor: 清理 13 处生产死代码
为什么:用 AST 级引用分析(而非文本 grep——同名符号会互相掩盖)确认这些
符号全项目零引用。文本 grep 查不出的原因是它们都有「活着的同名兄弟」:

services/downloads.py(共 6 个,93 行)
  _parse_cookie_str / _global_music_u / _vip_name / _detect_vip /
  _admin_client / _user_client
  —— 活的是 core.media.parse_cookie_str、web/routers/music.py、account.py 的版本
  随之失效的 import:USER_COOKIE_FILE / GLOBAL_MUSIC_U_CACHE / VIP_PROBE_LOCK /
  account_info / detect_vip_by_probe / QRLoginClient

services/api_service.py
  APIResponse 类(29 行)—— web/response.py 才是活的响应封装

services/_shared.py
  _rate_ok —— 逐字复制 web.ratelimit.rate_ok

core/utils.py
  safe_int / safe_console_print —— 活的是 web/request_utils.safe_int

core/repo.py
  all_settings / get_user_by_name —— auth_system.get_user_by_name 才是活的

core/db.py / web/auth_state.py / web/request_utils.py
  execute_many / _safe_register_credits + secrets_rand / dedup_ids

结果:生产代码死代码候选 13 → 0(余下 118 个全是 pytest 测试函数,属正常)。
2026-09-26 18:54:11 +08:00
snowy-yang 0b25f215a2 fix: 恢复任务监控线程的启动点(迁移中丢失,超时取消与账本对账静默失效)
为什么:d209af8 基线在 main.py:4594 用 threading.Thread(...).start() 启动
_task_monitor_loop;8446166「移除 Flask」把实现搬进 services/runtime.py 时
只搬了函数定义、丢了启动点,6288a98 拆包后落到 services/tasks.py,HEAD 仍
无任何调用方——函数体完好,但一次都没跑过。

后果(自该次迁移起持续失效):
- 心跳超时不再暂停离线设备的批量任务,排队名额无法释放
- 离线 >30 分钟 / 运行 >4 小时的任务不再自动取消
- 终态任务记录、tasks/ 孤儿目录、downloads 缓存不再回收
- ledger_recover_orphans 停摆,预扣未退款的账永久悬挂在 open 状态

改法:新增 start_task_monitor(),加锁保证幂等(重复调用返回 False),
在 create_app 的 lifespan 里用 asyncio.to_thread 启动;新增 start_monitor
开关供测试显式关闭。裸 TestClient(app) 不进上下文管理器、不触发 lifespan,
故既有测试不受影响。

验证:pytest 新增 3 例(幂等 / lifespan 启动 / 可跳过);端到端 uvicorn 冒烟
日志出现「任务监控线程已启动」。
2026-09-26 18:53:40 +08:00
snowy-yang 095171e0b7 refactor: 两个根级游离模块并入 music_api 包,消除分层倒置
为什么:podcast_api.py(560 行) 与 qr_login.py(397 行) 既不属于任何包、也不是
入口脚本,却同时被 services 层与 web 层引用——`services/downloads.py` 直接
`from qr_login import ...`、`services/podcast.py` 直接 `import podcast_api`,
业务层反向依赖顶层游离模块,是本次架构体检里唯一的无争议分层倒置。
两者本就依赖 music_api(前者取 url_v1/APIConstants/APIException,后者取
QRLoginManager),与 music_api 下已有的 qr.py / compat.py 是同一层的同类物。

迁移(git 保留重命名历史):
- podcast_api.py -> music_api/podcast.py
- qr_login.py    -> music_api/qr_client.py

**命名避让**:不叫 music_api/qr_login.py——music_api/__init__ 已经从 .compat
导出了一个**同名函数** qr_login,同名子模块会与之互相遮蔽。改叫 qr_client
(其核心导出即 QRLoginClient),并在验证里专门确认 music_api.qr_login 仍是
可调用函数、music_api.podcast 是模块,两者不冲突。

内部导入顺带改为精确子模块,不再绕道包门面:
- podcast.py:from music_api import APIConstants/APIException/url_v1
  → from .compat import url_v1 / from .constants import ... / from .errors import ...
- qr_client.py:删掉那层 try/except ImportError 包装(它给的是「请确保
  music_api.py 文件存在」这类顶层脚本时代的提示,作为包内模块已无意义),
  改 from .cookies/.errors/.qr 相对导入
- CLI 用法文本 python qr_login.py → python -m music_api.qr_client
  (相对导入后直接跑脚本会失败,必须走 -m;全仓确认无脚本/文档引用旧入口)
- 路径安全性已核对:qr_client 用的是 CWD 相对的 cookie.txt,不是 __file__ 相对,
  迁移不改其解析结果

引用点全部更新(AST 扫描确认覆盖全集,替换后残留 0):
- podcast:services/podcast.py(1 + 5 处调用) · web/routers/podcast.py(1 + 6)
  · web/routers/download.py(2 处函数内 + 3),导入名统一为 `podcast`
- qr_login:services/downloads.py · web/routers/admin.py · web/routers/user.py(×2)
- services/__init__.py 依赖自检列表去掉这两个名字(已并入 music_api)
- pyproject include 去掉两个顶层文件名(music_api 目录已覆盖)

验证:
- 11 + 3 个符号在新位置全部 hasattr 通过;podcast_api / qr_login 已
  ModuleNotFoundError;6 个调用方模块均可 import;services 依赖自检仍生效
- 门禁:ruff 0 · pyright 0 errors/0 warnings · pytest 184 passed
- 真实 uvicorn 冒烟 10/10,含 /podcast/parse 与 /podcast/hot 返回 401
  (模块导入失败会是 500,路由缺失会是 404,401 证明两者都正常)
- 生产 data/ 指纹 before == after
2026-09-26 15:03:47 +08:00
snowy-yang 1d86ec87ec refactor: 抽两个 zip worker 的重复实现,并补齐暂停/恢复路径的测试
为什么先测后改:_batch_zip_worker(347 行) 与 _podcast_zip_worker(295 行) 近乎
复制——连 _wait_if_paused 那 16 行都逐字相同(注释只差一行)。重复的代价是实打实
的:上一次修的 zip_valid「变量可能未绑定」缺陷就是**同型出现在这两处**。
但暂停/恢复是一条并发状态机,此前**零测试覆盖**,直接改不符合先测量后改动。

所以本 commit 分两步落地。

第一步(新增 tests/test_zip_workers.py,9 个用例)——行为级契约
断言只用「调用 worker 后世界里发生了什么」:任务终态、ZIP 内条目、账本守恒
(delivered / refunded)、暂停/恢复/取消后的余额。不引用任何内部函数名,
这样第二步重构时测试不必跟着改,才具备等价性验证能力。
- batch:正常完成 / 暂停→恢复 / 暂停→取消 / 全失败退费 /
  循环中途取消 / 发布前一刻取消
- podcast:正常完成 / 暂停→恢复 / 全失败退费
- 脚手架一律用 reserve_and_open 真预扣(ledger_open 只记账不扣款,
  会让「退款」凭空给余额加次数,守恒断言就失去意义)

写测试过程中暴露两处代码与注释不符,如实钉住而非粉饰:
1. **循环内取消并不会「交付已打包部分」**。zip_valid = True 位于 with zipfile
   块**之后**,而 while 循环内的任何取消都以异常**穿过**该块
   (zipfile.__exit__ 仍写出中央目录,但那行赋值不执行)→ zip_valid 恒为 False
   → delivered = ok_count if zip_valid else 0 恒为 0。源码注释所称的
   「部分成功后取消照常交付(成果守恒)」只在发布前最后一个检查点可达,
   已分别用两个用例把两条路径都钉住。
2. podcast 的 _wait_if_paused 与 batch 版本除 docstring 外完全一致。

第二步——抽取(只抽逐字相同、零语义差异的部分)
- _taskcore._wait_if_paused(task, pause_ev, cancel_ev, done):done 写成参数
  而非捕获闭包,使函数脱离两个 worker 各自的局部状态
- _taskcore._task_work_dir(task_id):任务专属目录约定(ZIP 与中间文件都落
  tasks/<task_id>/,对根目录的缓存清扫免疫)
- 两个 worker 各自的 16 行副本与 2 行 mkdir 删除,调用点改为显式传参

复杂度实测量(同口径,git HEAD 对比):
  两个 worker 合计  行数 642 → 604(-38)  决策点 152 → 140(-12)
  新增共享实现      _wait_if_paused 27 行/7 决策点 · _task_work_dir 11 行/1
**净行数基本持平(总量 -38 +38)**——重复代码抽取的收益从来不是行数变少,
而是**唯一真源**:以后改暂停语义只需改一处,不会再出现「同型缺陷出现在两地」。

验证:pytest 184 passed(175 → 184,+9)· ruff 0 · pyright 0 errors/0 warnings
2026-09-26 14:57:28 +08:00
snowy-yang 7a70eef636 refactor: 删除 services.runtime 兼容门面,7 处调用点直连真实子模块
为什么:上一次分包重构把 1900 行巨石按职责拆成 9 个子模块后,留了一个
runtime.py 作为「原样再导出」的兼容门面,好让调用点零改动。但迁移期结束后
没有一个调用点迁走——门面成了永久中间层:每次读代码都要先跳一层才知道
符号真正住在哪,pyright 也无法从门面追溯定义位置。

改法:保持「函数内延迟导入」的形式不变,只把导入目标换成真实子模块。
**刻意不提到模块顶部**——services/__init__.py 里有依赖自检
(缺 auth_system/podcast_api/music_api/music_downloader/qr_login 就 sys.exit(1)),
提到顶层会让「import web.*」这一步就触发该自检,改变启动语义,超出本次范围。

7 处调用点(AST 精确扫描确认全集):
- web/routers/download.py ×4:download / download_batch_zip / podcast_download /
  podcast_batch_zip,分别直连 services.downloads / batch / podcast / tasks
  外加 _shared._build_download_base 与 api_service.api_service
- web/routers/tasks.py ×2:_semaphores 取 _shared.BATCH_ZIP_SEM + api_service,
  topen 取 _shared.ENDPOINT_NAMES
- web/tasks_registry.py ×1:start_task 直连 _taskcore._start_task_thread

子模块短名统一加 `services_` 前缀别名(services_tasks 等),避免与函数内
既有局部名相撞。

顺带清理:9 个子模块 docstring 里「(自 services/runtime.py 拆出……)」的
历史说明在门面删除后已成悬空引用,一并去掉;包的模块划分说明改为
「不再有兼容门面层」。

验证:
- 符号可达:11 个被引符号在真实子模块上全部 hasattr 通过;
  services.runtime 已 ModuleNotFoundError;api_service 仍是同一实例对象
- 三个调用方模块均可正常 import
- 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed
- 真实 uvicorn 冒烟 8/8(含 POST /auth/login 400),临时数据目录独立落库
  9 张表,生产 data/ 指纹三项一致
2026-09-26 14:48:05 +08:00
snowy-yang 183f5744d8 fix: 修复 7 处变量可能未绑定的真缺陷,类型检查升到 standard
为什么:IDE 里成片的黄色波浪线并非风格噪声。ruff 与 pyright(basic) 都是
0 报错,但 basic 会整体漏报「变量可能未绑定」这类**可执行缺陷**;实测
standard 多报 7 处且全部为真——异常处理器引用了尚未赋值的局部变量,一旦走进
异常分支就会 NameError 二次崩溃,把原始异常彻底掩盖。

- core/db.py, core/http_client.py:3 处 @asynccontextmanager 的返回标注
  AsyncIterator[X] 更正为 AsyncGenerator[X, None](前者不含 send 语义)
- core/state.py:三个模块级缓存补 TypedDict 建模。原先无标注的 dict 字面量被
  推断为「所有值类型的并集」(float | None),每个读写点都退化成联合类型;
  拆成 _VipCache(data 可为 None 表示未探测)与 _VipEntry(data 必定存在)后,
  类型检查立刻抓出 account.py 对未探测缓存的直接解包隐患
- services/batch.py:zip_valid 提到函数头。ZIP 构建前的业务错误(参数缺失/
  无歌曲)会 early raise,而异常处理器引用了该名字 → NameError 二次崩溃
- services/podcast.py:radio_name、pct 同理补初始化
- services/downloads.py:声明返回 str 却可能给出 None,改 `or ''` 满足契约
- web/routers/download.py:id_list 提前绑定
- web/auth_state.py:解包出的 c 未被使用
- pyproject.toml:typeCheckingMode 由 basic 升到 standard 作为门禁,并移除
  已被 standard 覆盖的 reportGeneralTypeIssues

门禁:ruff All checks passed · pyright(standard) 0 errors 0 warnings
2026-09-26 13:54:23 +08:00
snowy-yang da3d013e9c chore: 清理前端与容器脚本中指向已删台账的过时编号
模板/样式/脚本的注释里散落着「第 N 条」「F59」「P3-43」这类编号,它们指向的
修复验收台账与交接文档已在本轮删除——留着既检索不到、也对不上号,还让后来读
注释的人以为存在一份可查的编号清单。本次只动注释,正文零改动。

覆盖范围(全仓一致):
- templates/index.html 163 处、static/site_auth.js 55 处,共 218 处编号整段摘除
  (含「第 N 条(Px):」前缀与「(Fxx 短标签)」括号)。
- 容器/配置 16 处:docker-compose.yml、entrypoint.sh(F59 / 第 93 条 / 第 97 条)、
  Dockerfile(T7 / 第 97 条)、.dockerignore(T1 / 第 97 条 / 第 55 条 / P2-9)、
  .env.example(F59 / 第 100 条 / 「详见交接文档 F11」这条失效指引)、.gitignore(P3-41)。
- Python 4 处:core/config.py(D-1)、services/tasks.py(O-5×2)、services/_shared.py(O-5)
  ——上一轮 Python 侧清理的漏网。

为什么规则要收窄(两类误伤,实测踩到):
1. 标签首字母不能用泛化的 [A-Z]:X5 是 QQ 浏览器内核、ES5 是 JS 版本,都不是编号。
   泛化后会把「微信 XWeb/X5、QQ 内置」改成「微信 XWeb/、QQ 内置」,把
   「Chromium <87 的老 X5 不识别 inset」改成「老 不识别」。现按本仓真实用过的
   任务族白名单收窄(A B C D F O P R S T U),X5/ES5 原样保留。
2. 括号内「编号 + 长说明」不能整段删:「(F51 只防了切歌,这里补同歌翻页)」
   整段删掉会连说明一起丢;现只摘编号、保留说明文字。

为什么同步改黄金契约快照:题头注释属于模板内容,改了注释首页字节数必然变
(466261 → 464333),tests/golden/responses.json 的 index 条目按既定流程刷新
html_len/html_sha256;并在 tests/test_golden.py 补一段说明,写清该条目记的是
「当前模板」而非 Flask 期数值、以及有意改模板后如何重算
(html_len = 文件字节数 - 1,sha256 = 文件去掉末尾换行后的哈希)。

验证(全部通过):
- 与 HEAD 比对:剥离全部注释后的正文逐行一致(唯一差异是删掉了一处行尾空注释),
  行数不变、每行半角括号/引号数量不变 → 代码与字符串零改动;
- node --check 通过(静态 JS + index.html 内联脚本),<style> 块花括号平衡;
- 真实 uvicorn 冒烟:/health /user/status /api/topen → 200、/admin/site/backup/list → 403,
  /static/site_auth.js 响应体与本地文件 sha1 相同,/ 无「第 N 条」残留;
- ruff All checks passed;pyright 0 errors / 0 warnings;pytest 144 passed。
2026-09-26 13:18:44 +08:00
snowy-yang 6288a98ecc refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留
大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述,
会误导后来者按不复存在的架构去改代码。

拆分(对外导入路径不变,行为零变更):
- music_downloader.py  -> music_downloader/(models/downloader/cover/_http/_log/_concurrency)
- auth_system.py       -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...)
- music_api.py         -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors)
- services/runtime.py  -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/
  streaming/tasks);services/runtime.py 保留为兼容门面

兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点
(auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性
动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。

修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字
等价」校验逐一定位,静态检查只会报出其中 4 处):
- core/config.py  ).strip() or '*'
- core/state.py   threading.Lock() x2
- qr_login.py     datetime.now() / .strip()
- music_downloader/downloader.py  .lower()
- services/podcast.py  set() / time.time() x2

清理与删除:
- 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt
- 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等
  失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明)
- _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含
- pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败

验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed;
真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
2026-09-26 12:37:48 +08:00
snowy-yang 838d635bc5 fix: 修复重构遗留缺陷,补齐 pyright 检查盲区
上一次重构(8446166)声称「pyright 0 错误」,但 pyproject 里 pyright 的 include
只覆盖 core/web/services —— 根目录 6 个应用模块与整个 tests/ 从未被检查,而
requests→sync_http 迁移的遗留恰好全部藏在那里。补齐 include 后立刻暴露
7 errors + 9 warnings,其中数处在运行时静默失效。

修复:
- services/runtime.py:删除 _podcast_billing_count/_billing_ids_count 两个
  无调用方的死函数。它们仍调用 api_service._safe_get_request_data(),而该方法
  在剥离 Flask 时已被删除,异常又被 except 吞掉 —— 一旦被调用,播客/歌单计费
  恒为 1 次且不报任何错。路由层已各自实现同口径版本,故整体删除。
- web/routers/admin_site.py:备份上传的类型判定必须用 starlette 的 UploadFile。
  fastapi.UploadFile 是它的子类,而 request.form() 返回基类实例,用错会让
  isinstance 恒为 False,把每一次真实上传都判成「请选择数据库文件」。
  已补 4 例真实 multipart 回归测试,并用变异测试确认能抓住该缺陷。
- auth_system.py:修正 create_user(实返 sqlite3.Row|None)与 list_cards
  (实返 (cards,total) 元组)与实现不符的返回注解;注解一改正,立刻暴露出被它
  掩盖的 register.py 中 int(user['id']) 未做 None 检查的 500 崩溃路径,已补守卫。
- music_downloader.py:修正注解引用的 core.sync_http.Response(正确名应为
  SyncResponse);封面缓存淘汰不再读类型为 Optional 的 deque.maxlen,改用命名常量。
- services/runtime.py:zip worker 的 result.music_info 可能为 None,原样使用会
  炸掉整批任务,改为按单曲失败处理。
- music_api.py:三处 isinstance 三元表达式收敛为 _as_dict() 帮助函数。
- web/main.py:文档串移除已删除的 Flask 兜底描述,补充 data/ 相对 CWD 解析的提醒。

验证:ruff check 通过;pyright 0 errors / 0 warnings(filesAnalyzed=67);
pytest 144 passed;真实 uvicorn 启动冒烟通过(/health、/、静态资源 200,
未鉴权管理端点 403)。生产库仅做只读 integrity_check,未被写入。
2026-09-26 11:52:06 +08:00
snowy-yang 8446166120 refactor!: uv 管理环境 + 彻底删除技术债(Flask 与 requests 全部移除)
## uv 环境管理
- pyproject.toml 声明 dependencies + [dependency-groups].dev,uv.lock 精确锁定
- 删除 requirements.txt / requirements-fastapi.txt / requirements-dev.txt
- .python-version = 3.12;Dockerfile 改用 uv sync --frozen(可复现构建)
- 新增 jinja2(首页渲染)

## 删除 Flask 技术债
- 从 main.py 用 ast 精确剥离 56 个 Flask 节点(路由/中间件/错误处理/启动),
  框架无关的服务层落为 services/runtime.py(MusicAPIService、下载/打包 worker、
  任务监控、缓存清扫),零 Flask 依赖
- 删除 main.py(4527 行)与 auth_routes.py(1050 行)
- auth_system.py 移除会话/请求上下文层(login_session/current_user/login_required/
  admin_required/_client_ip 等),保留数据与业务层
- 新增 core/net.py(IP 解析)、web/auth_state.py(登录锁定/2FA 票据/发码限流)
- FastAPI 侧改直接依赖 services.runtime,不再 import main
- web/app.py 去掉 Flask fallback;入口默认根路径模式(API_PREFIX 可选)

## 删除 requests 技术债
- 新增 core/sync_http.py:requests 风格的同步门面(底层 aiohttp 连接池,
  流式读取经有界队列背压),含 Session/headers/(connect,read) 元组超时兼容
- core/utils、auth_system、podcast_api、music_downloader、services 全部切换
- 实测真实下载 5.2MB 音频完整成功

## 质量门禁归零
- ruff:0 错误(B904 全部修复,不再忽略)
- pyright:0 错误(类型收窄修复)
- pytest:140/140(含 golden 契约回归、root 模式 66 项路径覆盖)
- 真实 uvicorn 启动:/health、/、/static、受保护端鉴权全部正确

新增 tests/golden/responses.json:删除 Flask 前捕获的 32 例响应快照,
FastAPI 逐字段复现——删除巨石后行为回归仍有硬证据。
2026-09-26 11:10:17 +08:00