From be4dd0646a7bb21b8d47bb3eceea55c72254eef5 Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Sun, 27 Sep 2026 11:11:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20/download=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E5=88=86=E6=94=AF=E6=8B=86=E8=A7=A3=EF=BC=88120=20=E8=A1=8C=20?= =?UTF-8?q?/=20=E5=9C=88=2019=20=E2=86=92=2078=20=E8=A1=8C=20/=20=E5=9C=88?= =?UTF-8?q?=2014=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为什么:单曲下载提交入口的 format=json 同步分支内嵌了完整事务 (取信息 → 下载 → 组响应 → 结算/退款,40 行),与参数校验、后台任务 登记混在一个函数里;可选参数解析 5 行两模式共用却内联两处。 拆出: - _parse_download_options:两模式共用的可选参数解析(lyric_format 非法静默回退 lrc 的历史口径保留) - _sync_download_json:同步下载事务整体——任一步失败退预扣(账本 守恒),只有成功才 settle(1, 'delivered'),docstring 写明语义 先写测试后动代码:新增 tests/test_download_sync.py 5 例,在未改动 实现上跑绿(修正 1 例预期:默认 filename_format=artist_song 产出 「歌手 - 歌名.mp3」),覆盖此前零测试的 json 同步分支——成功结算 余额恰 -1、取信息失败 404 退款、下载失败/异常退款、空 ID 专属文案。 门禁:ruff 全过 · pyright 0/0 · pytest 375 passed(370→375)。 --- tests/test_download_sync.py | 147 ++++++++++++++++++++++++++++++++++++ web/routers/download.py | 135 ++++++++++++++++++++------------- 2 files changed, 228 insertions(+), 54 deletions(-) create mode 100644 tests/test_download_sync.py diff --git a/tests/test_download_sync.py b/tests/test_download_sync.py new file mode 100644 index 0000000..fea873d --- /dev/null +++ b/tests/test_download_sync.py @@ -0,0 +1,147 @@ +# -*- coding: utf-8 -*- +"""/download 端点 json 同步模式的契约测试(沙箱,不触真实上游)。 + +test_download_billing.py 覆盖参数校验与退费口径;本文件钉住 format=json +的同步下载分支——它此前零覆盖,却是 40 行内嵌「取信息 → 下载 → 组响应 → +结算」的完整事务: +- 任一步失败:退回预扣(无 open 账本行)+ 专属文案 +- 成功:settle(1, 'delivered'),余额恰好 -1 + +替身边界:monkeypatch ``services.api_service.api_service.downloader`` +(路由内延迟导入取的就是这个实例属性),MusicInfo/DownloadResult 用 +真实 dataclass 构造。 +""" +import pytest +from fastapi.testclient import TestClient + +import auth_system as auth +import core.db as db +from music_downloader.models import MusicInfo + +SECRET = 'download-sync-test-secret' +PW = 'Passw0rd12' + + +def _info() -> MusicInfo: + return MusicInfo(id=347230, name='晴天', artists='周杰伦', album='叶惠美', + pic_url='', duration=269, track_number=1, + download_url='', file_type='mp3', file_size=1024, + quality='lossless') + + +def _dl(success: bool = True, msg: str = ''): + from music_downloader.models import DownloadResult + return DownloadResult(success=success, file_path='/tmp/x.mp3', + error_message=msg) + + +@pytest.fixture() +def env(tmp_path, monkeypatch): + db_path = tmp_path / 'site.db' + monkeypatch.setattr(auth.db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(auth.db, 'DB_PATH', db_path) + monkeypatch.setattr(auth.db, 'BACKUP_DIR', tmp_path / 'backups') + monkeypatch.setattr(auth.db, 'SECRET_KEY_PATH', tmp_path / 'secret.key') + monkeypatch.setattr(db, 'DATA_DIR', tmp_path) + monkeypatch.setattr(db, 'DB_PATH', db_path) + auth.init_db() + monkeypatch.setattr('web.deps.secret_key', lambda: SECRET) + auth.create_user('payer', PW, email='p@example.com', credits=3) + + state: dict = {'info_exc': None, 'dl_result': _dl(), 'dl_exc': None} + + class _FakeDownloader: + def get_music_info(self, music_id, quality, cookies): + if state['info_exc'] is not None: + raise state['info_exc'] + return state['info'] + + def download_music_file(self, music_id, quality, **kw): + if state['dl_exc'] is not None: + raise state['dl_exc'] + return state['dl_result'] + + state['info'] = _info() + from services.api_service import api_service + monkeypatch.setattr(api_service, 'downloader', _FakeDownloader()) + + from web.app import create_app + client = TestClient(create_app()) + + r = client.post('/auth/login', json={'username': 'payer', 'password': PW}) + raw = r.headers.get('set-cookie', '') + cookie = '' + for part in raw.split(';'): + if part.strip().startswith('session='): + cookie = part.strip()[len('session='):] + break + state['headers'] = {'Cookie': f'session={cookie}'} + state['client'] = client + return state + + +def _balance(username: str = 'payer') -> int: + u = auth.get_user_by_name(username) + assert u is not None, f'用户 {username} 不存在' + return int(u['duration_seconds'] or 0) + + +def _open_rows() -> list[dict]: + conn = auth.get_conn() + rows = conn.execute( + "SELECT task_id, status FROM billing_ledger WHERE status='open'").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def _post(env, **payload): + return env['client'].post('/download', json={'id': '347230', 'format': 'json', + **payload}, headers=env['headers']) + + +def test_json_success_settles_one_credit(env): + r = _post(env) + assert r.status_code == 200 + body = r.json() + assert body is not None and body['success'] is True + assert body['data']['name'] == '晴天' + assert body['data']['artist'] == '周杰伦' + assert body['data']['filename'] == '周杰伦 - 晴天.mp3' # 默认 filename_format=artist_song + assert body['data']['duration'] == 269 * 1000 + assert _balance() == 2 # 预扣 1,交付结算 + assert _open_rows() == [] + + +def test_json_info_failure_refunds(env): + env['info_exc'] = RuntimeError('song gone') + r = _post(env) + assert r.status_code == 404 + assert r.json()['message'] == '获取失败:歌曲不存在或暂无版权' + assert _balance() == 3 + assert _open_rows() == [] + + +def test_json_download_failure_refunds(env): + env['dl_result'] = _dl(success=False, msg='size mismatch') + r = _post(env) + assert r.status_code == 500 + assert r.json()['message'] == '下载失败,请稍后重试或更换音质' + assert _balance() == 3 + assert _open_rows() == [] + + +def test_json_download_exception_refunds(env): + env['dl_exc'] = RuntimeError('disk full') + r = _post(env) + assert r.status_code == 500 + assert r.json()['message'] == '服务暂时不可用,请稍后再试' + assert _balance() == 3 + assert _open_rows() == [] + + +def test_empty_id_refunds_with_specific_message(env): + r = env['client'].post('/download', json={'id': '', 'format': 'json'}, + headers=env['headers']) + assert r.json()['message'] == "参数 'music_id' 不能为空" + assert _balance() == 3 + assert _open_rows() == [] diff --git a/web/routers/download.py b/web/routers/download.py index 46d0459..508acfc 100644 --- a/web/routers/download.py +++ b/web/routers/download.py @@ -46,6 +46,75 @@ def _parse_meta_fields(raw: Any): return parse_meta_fields(raw) +def _parse_download_options(data: dict[str, Any]) -> tuple: + """file/json 两模式共用的可选参数解析。 + + 返回 (filename_format, write_meta, meta_fields, lyric_format, need_zip); + lyric_format 非法时静默回退 lrc(历史口径)。 + """ + filename_format = data.get('filename_format', 'artist_song') + write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') + meta_fields = _parse_meta_fields(data.get('meta_fields')) + lyric_format = data.get('lyric_format', 'lrc') + if lyric_format not in ('lrc', 'srt'): + lyric_format = 'lrc' + need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') + return filename_format, write_meta, meta_fields, lyric_format, need_zip + + +async def _sync_download_json(downloader, music_id: str, quality: str, cookies: dict, + filename_format: str, write_meta: bool, meta_fields, + billing, ip: str): + """format=json 的同步下载分支:取信息 → 下载 → 组响应 → 结算。 + + 语义:任一步失败退回预扣(账本守恒),只有成功才 settle(1, 'delivered')。 + """ + try: + info = await asyncio.to_thread( + downloader.get_music_info, int(music_id), quality, cookies) + except Exception as e: + logger.error('获取音乐信息失败: %s', e) + await billing.refund_open('rejected') + return error('获取失败:歌曲不存在或暂无版权', 404) + from services._shared import _build_download_base + base = await asyncio.to_thread( + _build_download_base, filename_format, + {'name': info.name, 'artist_string': info.artists}) + filename = f'{base}.{info.file_type}' + try: + dl = await asyncio.to_thread( + downloader.download_music_file, int(music_id), quality, + write_meta=write_meta, meta_fields=meta_fields, + cookies=cookies, music_info=info) + if not dl.success: + logger.error('下载失败: %s', dl.error_message) + await billing.refund_open('rejected') + return error('下载失败,请稍后重试或更换音质', 500) + except Exception as e: + logger.error('下载异常: %s', e) + await billing.refund_open('rejected') + return error('服务暂时不可用,请稍后再试', 500) + try: + _record_log('下载', ip, info.name) + except Exception: + pass + response_data = { + 'music_id': music_id, + 'name': info.name, + 'artist': info.artists.replace('/', ', '), + 'album': info.album, + 'quality': quality, + 'quality_name': media.quality_display_name(quality), + 'file_type': info.file_type, + 'file_size': info.file_size, + 'file_size_formatted': media.format_file_size(info.file_size), + 'filename': filename, + 'duration': info.duration * 1000, + } + await billing.settle(1, 'delivered') + return success(response_data, '下载完成') + + def _dedup_ids(raw_ids: Any) -> list[str]: @@ -72,7 +141,11 @@ async def _resolve_personal(data: dict[str, Any], user: SiteUser | None, request @router.post('/download') async def download(user: Annotated[SiteUser | None, Depends(current_user)], request: Request): - """下载单曲(file=后台任务 / json=同步返回)。""" + """下载单曲(file=后台任务 / json=同步返回)。 + + 顺序:限流 → 预扣计费 → 四道参数闸(失败即退款)→ 账号 Cookie → + 可选参数 → json 同步分支 / file 后台任务分支。 + """ await ratelimit.guard(request, bucket='dl', limit=20, window=60) data = await _request_data(request) music_id = data.get('id') @@ -107,66 +180,20 @@ async def download(user: Annotated[SiteUser | None, Depends(current_user)], requ else: cookies = _cookies() - filename_format = data.get('filename_format', 'artist_song') - write_meta = str(data.get('write_meta', 'true')).lower() in ('1', 'true', 'yes') - meta_fields = _parse_meta_fields(data.get('meta_fields')) - lyric_format = data.get('lyric_format', 'lrc') - if lyric_format not in ('lrc', 'srt'): - lyric_format = 'lrc' - need_zip = str(data.get('zip', 'false')).lower() in ('1', 'true', 'yes') + (filename_format, write_meta, meta_fields, + lyric_format, need_zip) = _parse_download_options(data) - from services import downloads as services_downloads - from services import tasks as services_tasks - from services._shared import _build_download_base from services.api_service import api_service downloader = api_service.downloader if return_format == 'json': - try: - info = await asyncio.to_thread( - downloader.get_music_info, int(music_id), quality, cookies) - except Exception as e: - logger.error('获取音乐信息失败: %s', e) - await billing.refund_open('rejected') - return error('获取失败:歌曲不存在或暂无版权', 404) - base = await asyncio.to_thread( - _build_download_base, filename_format, - {'name': info.name, 'artist_string': info.artists}) - filename = f'{base}.{info.file_type}' - try: - dl = await asyncio.to_thread( - downloader.download_music_file, int(music_id), quality, - write_meta=write_meta, meta_fields=meta_fields, - cookies=cookies, music_info=info) - if not dl.success: - logger.error('下载失败: %s', dl.error_message) - await billing.refund_open('rejected') - return error('下载失败,请稍后重试或更换音质', 500) - except Exception as e: - logger.error('下载异常: %s', e) - await billing.refund_open('rejected') - return error('服务暂时不可用,请稍后再试', 500) - try: - _record_log('下载', ip, info.name) - except Exception: - pass - response_data = { - 'music_id': music_id, - 'name': info.name, - 'artist': info.artists.replace('/', ', '), - 'album': info.album, - 'quality': quality, - 'quality_name': media.quality_display_name(quality), - 'file_type': info.file_type, - 'file_size': info.file_size, - 'file_size_formatted': media.format_file_size(info.file_size), - 'filename': filename, - 'duration': info.duration * 1000, - } - await billing.settle(1, 'delivered') - return success(response_data, '下载完成') + return await _sync_download_json( + downloader, music_id, quality, cookies, + filename_format, write_meta, meta_fields, billing, ip) # file 模式:注册后台任务 + from services import downloads as services_downloads + from services import tasks as services_tasks device_id = str(data.get('device_id') or '').strip()[:64] await asyncio.to_thread(services_tasks._touch_device, device_id) task = _single_task({