Commit Graph
23 Commits
Author SHA1 Message Date
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 2b4704183c refactor: session 改用 itsdangerous,去掉手写密码学原语
为什么:core/session.py 原本手写 HMAC + 自定义 base64 签名,等于自己复刻
itsdangerous 的核心功能——密码学原语自己实现是风险点,且难与上游的安全
修复同步。

关键约束(换库的坑):两者字节级格式并不一致——手写实现用标准 base64 且
保留 '=' 填充,itsdangerous 用 urlsafe 且去填充。若不钉住旧格式,部署瞬间
线上全部登录态会失效。

改法:
- 用 URLSafeTimedSerializer,按 secret 做 lru_cache(maxsize=8),避免每请求
  重建序列化器
- loads() 增加 max_age 参数(默认 None,保持既有「不叠加服务端过期」行为)
- get_uid() 顺带加固:cookie 里塞非整数不再抛 ValueError 变 500,改为返回 None
- 删除 _derive_key / _int_to_bytes 及手写 HMAC/b64

验证:新增 2 例回归测试,其中 legacy cookie 样本由替换前的旧实现在固定时间戳
1700000000 下采集,钉住的是格式而非实现;另一例验证 max_age=None 接受旧
时间戳、max_age=60 拒绝。
2026-09-26 18:53:47 +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 70ca21974b refactor: 拆掉 auth_system 包内循环依赖,路径常量收敛到 db 单一来源
为什么:auth_system 的 11 个子模块此前构成一个强连通分量——db.py 与 backup.py
为了「动态读取路径常量」而在模块级 `import auth_system`(包自身),而包 __init__
又再导出这两个子模块的全部符号,互相指成环。

这个环当前并不崩(实测 8/8 种导入姿势全通过),它的代价是**双绑定**:
`auth_system.DB_PATH is auth_system.db.DB_PATH` 初始为 True,但只 patch 其一
另一处不跟随。于是测试不得不把 4 个路径常量**两处都 patch**
(setattr(auth,...) + setattr(db,...))——而漏 patch 的后果是静默写生产库,
commit fc7fc21 修的那次「黄金测试写进生产库」正是这么来的。

改法:让路径常量回归**模块属性单一来源**。
- db.py:删掉 `import auth_system`,内部改用本模块常量(DATA_DIR / DB_PATH /
  SECRET_KEY_PATH)。这些常量本就定义在本模块,绕道包属性纯属多余。
- backup.py:改用 `import auth_system.db as db` 做模块限定访问,路径走
  db.BACKUP_DIR / db.DB_PATH——保留动态读取能力,来源从「包属性」换成
  「db 模块」,与唯一来源一致。
  注意**不能**写成 `from . import db`:该形式在静态图里同时指向包本身与子模块,
  会把这个环重新连上(实测确实如此,SCC 从 11 降到 2 就卡住了)。
- admin_site.py:5 处 `auth.DB_PATH` / `auth.BACKUP_DIR`(auth 是包别名)同样
  改为经 db 模块访问。**这处不修就会出事**——它读的是包属性快照,而测试已改为
  patch db 模块,于是备份上传会静默落到生产 data/backups/(本次实测真的发生了,
  2 个 upload_*.db 已清理,副本留在临时目录)。
- 测试:7 个文件的 patch 目标从包属性迁到 auth_system.db.*。
- __init__.py:补注释说明这些常量在此只是 import 快照,禁止经包属性重定向。

验证:
- Tarjan 重算:非平凡强连通分量 11 → **0**(import 图已是 DAG)
- AST 全仓扫描:经包属性访问路径常量的点 **0 处残留**
- 行为:子进程内 patch auth_system.db.* 后,get_conn 连的库 / create_backup
  落盘目录 / get_secret_key 目标路径**三者全部跟随**
- 导入姿势回归 8/8 通过
- 门禁:ruff 0 · pyright 0 errors 0 warnings · pytest 175 passed
2026-09-26 14:37:58 +08:00
snowy-yang ad8c30d932 refactor: 下载/标签/格式探测改用成熟库(aria2 + mutagen + filetype)
为什么:原先三处都在手写轮子——ffmpeg CLI 拼参数写标签、aiohttp 单连接手搓
下载、自己比对魔数判格式。手写部分既难测又易错;改走成熟库后测试从 144 涨到
175,并且彻底去掉了 ffmpeg 这个外部二进制依赖。

下载 → aria2c 常驻 JSON-RPC(aria2p 客户端),新增 _aria2.py
- 单例后端:首次调用时拉起 aria2c,只监听 127.0.0.1 且强制随机 secret
  (无鉴权的 aria2 RPC 允许本机任意进程操纵队列、读写任意文件)
- split=5 / max-connection-per-server=5 多连接 + 原生断点续传
- 异常语义与内置通道逐一对齐:取消/超限清残片抛 DownloadCanceled /
  DownloadException,任务失败/超时保留残片抛 RequestException 供续传
- 取不到 aria2c 时 get_backend() 返回 None,自动降级到内置 aiohttp 通道
  (本机无 aria2c 的降级路径已实测)

标签 → mutagen 纯 Python 写入,新增 tagging.py
- 取代 _write_metadata_with_ffmpeg + _build_format_args(226 行 shell 参数拼接)
- MP3(ID3v2.3)/FLAC/M4A/OGG/Opus 分容器实现,无子进程、不重编码
- 就地改帧:TXXX 等未触及的标签原样保留(ffmpeg 整帧重写会丢)
- 临时文件 + os.replace 原子替换,写坏不会留下半个文件
- 为什么不是 ffmpeg-python(原方案):实测它用 **kwargs 建模选项,无法表达
  重复键(-metadata ×N),list 值还会被字符串化成 "['0', '1:v']";-map 尚有
  内置的 stream 位置参数入口,但 -metadata 无解,且 overwrite_output=True
  生成的 -y 被排到输出文件之后、可能失效

格式探测 → filetype,改 _http.py
- 删除手写的 _AUDIO_MAGIC 魔数表,改用 filetype.guess() 判 MIME
  (audio/* 放行,video/mp4 作为容器显式放行)

配套
- Dockerfile:镜像不再装 ffmpeg,改装 aria2
- pyproject:新增 filetype / aria2p / mutagen;dev 加 httpx2(消除测试里的
  StarletteDeprecationWarning,现 0 warning)
- __main__.py / __init__.py / models.py:清理 ffmpeg 相关导出与文案

测试:新增 31 个用例(test_aria2.py 13、test_tagging.py 21,含最小合法 FLAC
样本构造);全套 175 passed / 0 warning
2026-09-26 13:54:44 +08:00
snowy-yang fc7fc21284 test: 黄金契约测试改用站点库副本,不再写生产库
tests/test_golden.py 的 fixture 直接指向 data/site.db 并注释为「只读」,但快照里
有 2 条 auth=user 的 POST /download(均返回 400),这条路径会 reserve_and_open
开账、失败后 settle_refund 退款——于是**每次 pytest 都往生产库插 2 行
billing_ledger**(status='rejected', reserved=1, refunded=1),并把该用户的
last_active 顶到测试时刻。

发现时生产库已累积 83 行这样的行(时间戳与历次 pytest 运行一一对应),库体积
也因此长了 2 个页(151552 → 159744)。净余额为 0(预扣 1、退款 1),不丢钱,
但污染台账与后台台账视图,而且让「跑测试不动生产数据」这个前提不再成立——
排查时无法再用「库指纹没变」来证明冒烟/测试是安全的。

改法:复制一份 data/site.db(含 -wal/-shm 边车文件)到 tmp_path,再把
auth_system.DB_PATH/DATA_DIR 与 core.db.DB_PATH/DATA_DIR 一并指向副本。
契约保真度不变(同一份真实数据、同一 schema),写入全部落在临时目录。
_session_for 也改为读 auth_system.DB_PATH,保证「造 Cookie」与「发请求」
命中的是同一个库(原先写死路径,换库后会静默读生产库拿到错误 login_seq)。

验证:改前改后各跑一次完整套件,data/site.db 的 sha1 / 体积 / mtime 三项完全
不变,billing_ledger 行数停在 83;144 passed;ruff All checks passed;
pyright 0 errors / 0 warnings。

注:生产库里既有的 83 行测试残留**未删除**(那是生产数据,需确认后再动);
ledger 自带保留期清扫(auth_system/ledger.py 的 DELETE ... settled_at <= ?)
到期会自行回收。
2026-09-26 13:21:26 +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 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
snowy-yang f5d1e3c3a9 feat: 支持纯 FastAPI 模式(根路径服务前端)+ 修任务端点路径
- web/app.py:create_app(api_prefix=) 参数化前缀——'' 即根路径模式,
  87 条路由直接服务前端使用的路径,无需 Flask 兜底
- web/main.py:WEB_MODE=dual(默认,迁移期)/ fastapi(纯 FastAPI)切换
- 修真实路径缺陷:任务类端点漏了 /api 段(Flask 为 /api/topen 等,
  原写成 /topen),根路径模式下前端会 404。已改为 /api/topen、/api/queue、
  /api/batch_progress、/api/task/*;fe_error 同步修正
- 奇偶校验路径映射同步更新

验证:
- tests/test_root_mode.py 新增 66 项:前端调用的每条路径在纯 FastAPI 模式下
  均非 404;root 模式不再暴露 /api/v1
- 纯 FastAPI 模式真实启动(uvicorn, PORT=5002)实测:
  /health、/、/static 200,/song、/api/task/list 401(鉴权生效)
- 全套 pytest 135/135;ruff All checks passed;pyright 0 errors;奇偶 36/36
2026-09-26 10:25:05 +08:00
snowy-yang 9df0028195 feat(routes): 迁移下载/批量打包/播客下载(账本事务)+ 修密钥绑定缺陷
迁移端点:
- POST /api/v1/download(单曲:file 后台任务 / json 同步交付+结算)
- POST /api/v1/download/batch_zip(按去重歌曲数计费)
- POST /api/v1/podcast/download、/podcast/download/batch_zip
- 新增 web/billing.py(预扣+开账同事务 / 只退 open 行 / rename 承接)
  与 web/tasks_registry.py(任务登记/容量闸/账本撞名重试,与 main 同口径)
- worker 仍复用 main 既有实现(无 flask 依赖),账本与交付语义不变

修复我引入的真实缺陷:
- download 参数校验分支(ID/音质/格式非法)漏退款 → 预扣泄漏。改为统一 _fail() 退款
- web/routers/{auth,register}.py 在导入期绑定 deps.secret_key,导致 patch web.deps.secret_key
  无效、且测试顺序相关。改为调用期 deps.secret_key() 解析

测试:tests/test_download_billing.py 7 项沙箱用例如实断言账本守恒
(预扣必退、无 open 行残留、次数不足不建任务);
全套 pytest 69/69(顺序无关);ruff All checks passed;pyright 0 errors;奇偶 36/36。
2026-09-26 10:13:16 +08:00
snowy-yang 915358405c feat(routes): 迁移注册/找回密码/卡密/验证码端点
- web/routers/register.py:auth/send_code、auth/register、auth/trial、
  auth/redeem、auth/forgot_password
- 同步调用(SQLite 事务/SMTP/PBKDF2)走 asyncio.to_thread;发码 IP 限流复用
  auth_routes._SEND_CODE_IP(跨栈共享计数)
- 注册成功路径签发会话 Cookie;管理员找回密码仍防枚举拦截

测试:tests/test_register.py 6 项沙箱用例(播种验证码绕 SMTP)——
协议门槛、注册成功建号+赠送+会话可用、验证码错误、找回重置后可登录、
管理员找回被拦、卡密充值。
全套:pytest 62/62;ruff All checks passed;pyright 0 errors;冒烟 10/10。
2026-09-26 10:00:39 +08:00
snowy-yang 91c032a1c3 feat(routes): 迁移认证端点(login/logout/heartbeat/ann_seen)
- web/routers/auth.py
  · login:邮箱/用户名登录、超长密码短路、失败锁定、封禁/禁用分支、
    管理员转 2FA 票据;成功签发 core.session 会话 Cookie(与 Flask 同格式)
  · logout:递增 login_seq 顶号 + 删 Cookie
  · heartbeat:次数校验(401/expired 语义同 Flask)
  · ann_seen:记录已读公告版本
  · 失败锁定与 2FA 票据表复用 auth_routes 模块级状态(跨栈共享,防绕锁定)
  · PBKDF2 与库操作走 asyncio.to_thread,不阻塞事件循环

测试:tests/test_auth_routes.py 5 项,全部使用**沙箱库**(pytest tmp_path +
monkeypatch auth_system/core.db 路径),不触碰生产 data/site.db。
全套:pytest 56/56;ruff All checks passed;pyright 0 errors;奇偶校验 28/28。
2026-09-26 09:54:14 +08:00
snowy-yang 0104ae55e5 feat(routes): 迁移音频流代理与结果交付端点(流式)
- web/routers/stream.py:/api/v1/audio_stream 音频范围流代理
  · 签名校验(core.security,密钥同源两栈互认);HEAD 直接 200 不请求上游
  · Range 原样透传上游(206 + Content-Range,拖动进度可用)
  · 编码透明:上游带 Content-Encoding 时不沿用压缩前长度(同 Flask F92)
  · StreamingResponse 16KB 分块,finally 关闭上游(下游中断亦释放)
- core/http_client.py 增 open():返回未关闭流式响应,便于先读状态码再转发
- web/routers/delivery.py:/download/result、/download/batch_zip/result
  · 归属 403 / 进行中 202 / 暂停 200 / 取消 200 / 失败 500 / 缺失 404 分支逐一对齐
  · FileResponse 流式交付 + no-store + X-Download-Filename;清理由监控线程统一回收

验证:pytest 51/51(新增 5 项流代理:无效 token/HEAD/全量/Range 透传/上游关闭);
ruff All checks passed;pyright 0 errors;奇偶校验 26/26;实测 5.2MB 流与
Range 206 正确。
2026-09-26 09:49:22 +08:00
snowy-yang 6c3181341b feat(routes): 迁移账号/全局歌单端点 + 鉴权依赖测试
迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐):
- account/vip:admin/user 双路径 + 5 分钟缓存(降级结果 30s),
  VIP 探测单飞锁与 Flask 同源跨栈共享;未登录可访问(同 Flask)
- global/playlists、global/playlist:管理员闸(require_admin,403 对齐)
- web/deps.py 新增 require_admin;_resolve_user_cookies 支持可选用户(匿名审计保底)

测试:46/46(新增 10 项鉴权依赖:有效/管理员/封禁/禁用/顶号/伪造 Cookie、
401/403 闸);ruff All checks passed;pyright 0 errors。

契约奇偶校验:16 项全 OK(新增 account:vip、global:playlists/playlist、
非管理员 403);uvicorn 实跑双栈 account/vip 均 200、未授权歌单 403。
2026-09-26 01:15:22 +08:00
snowy-yang a2758aa0da feat(routes): 迁移解析端点 /song /playlist /album + 响应卫生中间件
迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐):
- song(type: url/name/lyric/json)、playlist、album
  个人账号路径实现全局账号冒用拦截(同 Flask 口径);json 模式 asyncio.gather 并发
  song/url/lyric(歌词失败不阻断,与 Flask 线程池语义一致)
- core/media.py:format_file_size/quality_display_name/parse_cookie_str 纯函数复用

web/middleware.py(镜像 Flask after_request):
- 音频直链 → HMAC 签名代理地址(密钥同源,两栈 token 互认)
- 安全头 nosniff/DENY/Referrer-Policy/CSP 基线;Server 抹为 NeteaseUrl;429 带 Retry-After
- 纯 ASGI 逐段转发(不破坏流式下载);JSON 200 延迟 start 并**重算 Content-Length**
  ——修实测 IncompleteRead(原实现转发旧长度 + 新 body)

契约奇偶校验:12 项全 OK(新增 album/playlist/song×3);
token/CDN 主机归一化(均为易变值,非契约漂移)。

验证:pytest 36/36;ruff All checks passed;pyright 0 errors;
uvicorn 实跑 /api/v1/song 直链已代理、CSP/Server 头正确。
2026-09-26 01:12:54 +08:00
snowy-yang 2c55b55e03 feat(routes): 迁移查询类路由到 FastAPI + 会话互通 + 统一错误/限流
会话互通(迁移关键):
- core/session.py:itsdangerous 兼容的签名 Cookie 编解码(同密钥/同 salt/同
  时间戳编码),Flask 与 FastAPI 双向可读——实测 F->core 与 core->F 双向解出一致,
  篡改/错密钥返回 None

FastAPI 基础设施:
- web/deps.py    会话→当前用户(封禁/顶号校验,口径同 auth_system.current_user)
- web/errors.py  AppError + 全局异常处理,404/405/400 统一为本站包体
- web/ratelimit.py 复用 core.state 同一批限流桶(跨栈共享,避免绕框架绕过限流)
- core/repo.py  补 get_user_by_id(异步)

迁移路由(/api/v1/*,与 Flask 同端点逐字段对齐):
- toplist / artist_songs / comments / search,走 music_api 异步方法
- search 的服务器昵称遮蔽改为 FastAPI 侧异步实现(不再依赖 main)

契约奇偶校验:扩展至 7 项(含登录会话互通 + 未登录 401),全 OK;
CDN 分片主机差异归一化(上游易变值,非契约漂移)。

验证:pytest 33/33;ruff All checks passed;pyright 0 errors;
uvicorn 实跑未登录 401 包体与 Flask 一致。
2026-09-26 01:09:01 +08:00
snowy-yang c77ef40fd5 feat(db): 同步栈读写并发(_q 去锁)+ core.db 并发/跨loop 修复
auth_system._q:单语句读不再持 _DB_LOCK —— WAL 下每连接读一致性快照,
读写互不阻塞;旧实现把全部读也串行化且被写挡住。写事务(_x 与 24 个
with _DB_LOCK 块,经扫描均为写事务)仍全程串行,无写-写竞争。

core.db 并发修复(由测试暴露的真实缺陷):
- WAL 切换竞态:并发连接同时执行 PRAGMA journal_mode=WAL 争排他锁
  → 专用锁内串行切换 + 二次复查(busy_timeout 前置)
- 跨事件循环锁:模块级 asyncio.Lock 绑定首个 loop,多 loop 场景报
  "bound to a different event loop" → 锁按 loop 隔离

测试:25/25 通过(新增「读不被写锁阻塞」红转绿、读+同步写者并发 WAL、
异步并发读);
lint: ruff All checks passed;pyright 0 errors。
验收:冒烟 10/10、奇偶校验 2/2、uvicorn 真实启动 Flask 兜底+FastAPI 双通。
2026-09-26 01:03:18 +08:00
snowy-yang 0f2e72db44 feat(db-async): 站点数据异步读写仓储 + FastAPI 生命周期
- core/repo.py:经由 core.db(aiosqlite) 读写 auth_system 的**同一份** site.db
  (settings/announcements/users 只读/ledger 只读),字段口径对齐 auth_system;
  读并发、写串行、WAL 读写同时进行
- web/routers/site.py:/api/v1/site/announcements、/api/v1/site/stats(异步多表并发读)
- web/app.py:lifespan 关闭时释放 aiohttp 连接池(含按 loop 隔离的 session)

写路径说明:迁移期写操作仍归 auth_system 的 _DB_LOCK 事务口径,避免两套写
路径互相踩踏;本层先只做读。

验证:pytest 22/22(新增 6 项含「异步读 + 同步写者并发 WAL」);
/api/v1/site/* 实测读到共享库(3 用户/1 管理员);ruff/pyright 干净。
2026-09-26 00:34:11 +08:00
snowy-yang 4962542e78 feat(core): requests 兼容异步垫片 + 同步/异步桥
- core/aiohttp_compat.py:以 requests 同形 API(post/get + Response.json/text/
  status_code/raise_for_status + HTTPError/RequestException/Timeout)实现异步请求,
  使既有 requests 风格代码可「加 await + async def」最小改动迁移;4xx/5xx 不抛、
  仅 raise_for_status() 抛(与 requests 语义一致,保住风控判断逻辑)
- core/async_bridge.py:run_sync(coro) 经常驻后台事件循环线程执行协程并阻塞等待,
  供迁移期同步调用方(遗留 Flask 路由/线程池)复用异步实现

验证:pytest 16/16;ruff All checks passed;pyright 0 errors。
2026-09-26 00:27:47 +08:00
snowy-yang 9f56f68b66 chore(async-infra): 异步基础层 + 工具链 + 清理
异步基础层(core/):
- db.py   aiosqlite + WAL:读路径无锁并发、写路径 asyncio.Lock 串行,
          busy_timeout 自旋——实现「读写同时进行」(旧实现用 RLock 全读串行)
- http_client.py  aiohttp 连接池 + 有界重试(5xx 重试 / 4xx 不重试)+
          响应体上限 + 流式接口;按事件循环隔离 session(支持迁移期双 loop)

工具链:
- pyproject.toml   ruff(E/F/W/I/B/UP/C4/ASYNC/S/RUF) + pyright(basic) + pytest(asyncio auto)
- requirements-fastapi.txt  FastAPI/aiohttp/aiosqlite/aiofiles 等目标栈
- requirements-dev.txt      开发依赖(pytest/ruff/pyright)

清理:删除无用脚本 _csscheck.py 与产物标记 _css_ok.txt/_pkg_ok.txt,同步 .gitignore

验证:pytest 10/10(含「写 200 条同时 4 读协程零异常」并发用例);
ruff All checks passed;pyright 0 errors。
2026-09-26 00:24:00 +08:00