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/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
snowy-yang committed 2026-09-26 12:37:48 +08:00
1 parent 838d635bc5
commit 6288a98ecc
89 files changed
+5806 -5112

No files matched your search

+5 -5
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""账号与全局歌单端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""账号与全局歌单端点。
已迁移:
端点:
- POST/GET /api/v1/account/vip ↔ Flask /account/vip(admin/user 双路径 + 缓存)
- POST/GET /api/v1/global/playlists ↔ Flask /global/playlists(管理员)
- POST/GET /api/v1/global/playlist ↔ Flask /global/playlist(管理员)
@@ -31,7 +31,7 @@ _VIP_NAMES = {
0: '普通用户', 1: '音乐包', 11: '黑胶VIP', 100: '黑胶VIP', 10: '黑胶SVIP', 110: '黑胶SVIP',
}
# 探测单飞:与 Flask _VIP_PROBE_LOCK 同源(跨栈共享,避免两栈各自串行 8 级探测)
# 探测单飞:与 core.state 中同一把锁共享,避免并发请求各自串行 8 级探测
_VIP_PROCESS_LOCK: Any = VIP_PROBE_LOCK
@@ -44,7 +44,7 @@ def _cookies() -> dict[str, str]:
async def _detect_vip(cookies: dict[str, str]) -> tuple[int, str, list[str]]:
"""检测账号会员类型与可解析音质(与 Flask _detect_vip 同口径)。
"""检测账号会员类型与可解析音质。
单飞锁为 threading.Lock:异步上下文里用 to_thread 有界等待 0.5s,
拿不到锁的请求直接用账号接口给降级结果(不等 8 级探测)。
@@ -100,7 +100,7 @@ async def account_vip(
'vip_name': _vip_name(vip_type), 'detected_by': method, 'levels_ok': levels_ok,
'nickname': (info or {}).get('nickname', ''),
}
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s(与 Flask 同口径)
# 降级结果(levels_ok 空)短缓存 30s,正常结果 300s
ttl = 30 if not levels_ok else 300
if len(VIP_USER_CACHE) > 500:
VIP_USER_CACHE.clear()
+5 -5
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""管理员扫码登录 / Cookie 管理 / 操作记录(FastAPI 栈)。
"""管理员扫码登录 / Cookie 管理 / 操作记录。
已迁移:
端点:
- POST /api/v1/admin/qr/create ↔ Flask /admin/qr/create
- POST /api/v1/admin/qr/check ↔ Flask /admin/qr/check
- POST /api/v1/admin/cookie/status ↔ Flask /admin/cookie/status
- POST /api/v1/admin/cookie/delete ↔ Flask /admin/cookie/delete
- POST /api/v1/admin/logs ↔ Flask /admin/logs
鉴权:Flask 这些端点用 _verify_admin_password(文案"需要管理员权限,请使用管理员账号登录"),
鉴权:管理员密码闸(文案"需要管理员权限,请使用管理员账号登录"),
与 auth_system.admin_required 的文案不同,故本模块用本地 admin 闸保持逐字对齐。
说明:QRLoginManager 已异步化(直接 await);QRLoginClient 的 save_cookie/logout/
@@ -34,7 +34,7 @@ logger = logging.getLogger('web.admin')
async def _require_admin(user: Annotated[SiteUser | None, Depends(current_user)]) -> SiteUser:
"""管理员闸(文案与 Flask _verify_admin_password 逐字对齐)。"""
"""管理员闸(文案与历史实现逐字对齐)。"""
if user is None or not user.is_admin:
raise AppError('需要管理员权限,请使用管理员账号登录', 403)
return user
@@ -52,7 +52,7 @@ def _admin_client():
def _reset_caches() -> None:
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存(与 Flask 同口径)。"""
"""Cookie 变更后清 VIP 探测缓存与全局 MUSIC_U 缓存。"""
VIP_CACHE['data'] = None
VIP_CACHE['ts'] = 0.0
GLOBAL_MUSIC_U_CACHE['v'] = None
+2 -3
View File
@@ -1,11 +1,10 @@
# -*- coding: utf-8 -*-
"""管理后台(/admin/site/*)端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
"""管理后台(/admin/site/*)端点。
鉴权:与 auth_system.admin_required 同口径(403 + error_code='forbidden')。
说明:这些端点多为对 auth_system 同步函数(SQLite / SMTP / 文件)的薄封装,
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名
(与 Flask _actor 同口径)。
统一经 asyncio.to_thread 执行以不阻塞事件循环;操作者名取当前管理员用户名。
"""
import asyncio
import math
+6 -6
View File
@@ -1,18 +1,18 @@
# -*- coding: utf-8 -*-
"""认证操作端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""认证操作端点。
已迁移:
端点:
- POST /api/v1/auth/logout ↔ Flask /auth/logout(顶号:递增 login_seq + 删 Cookie)
- POST /api/v1/auth/heartbeat ↔ Flask /auth/heartbeat
- POST /api/v1/auth/ann_seen ↔ Flask /auth/ann_seen
- POST /api/v1/auth/login ↔ Flask /auth/login(密码校验 + 失败锁定 + 管理员 2FA 转票据)
设计:
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,两栈共享同一份
- 登录失败锁定与管理员 2FA 票据表**复用 web.auth_state 的模块级状态**,全进程共享同一份
计数/票据,避免「换个框架打就绕过锁定」。
- 密码校验(PBKDF2,10 万次迭代,CPU 密集)与库操作经 asyncio.to_thread 执行,
不阻塞事件循环。
- 会话 Cookie 由 core.session 签发(与 Flask 同格式同密钥),删 Cookie 亦同口径。
- 会话 Cookie 由 core.session 签发(沿用同一格式与密钥,存量会话继续有效),删 Cookie 亦同口径。
"""
import asyncio
import os
@@ -52,7 +52,7 @@ def _cookie_kwargs() -> dict:
def _login_response(payload: dict, message: str, uid: int, seq: int) -> JSONResponse:
"""构造带会话 Cookie 的成功响应(Cookie 格式与 Flask 一致)。"""
"""构造带会话 Cookie 的成功响应(沿用同一 Cookie 格式)。"""
resp = JSONResponse(content={'status': 200, 'success': True, 'message': message, 'data': payload})
cookie = cs.dumps(deps.secret_key(), {
'site_uid': uid, 'site_uid_seq': seq, 'site_uid_ts': int(time.time()),
@@ -78,7 +78,7 @@ async def logout(user: Annotated[SiteUser | None, Depends(current_user)]):
@router.post('/auth/heartbeat')
async def heartbeat(user: Annotated[SiteUser | None, Depends(current_user)]):
"""会话保活:返回最新次数;超限/失效给 401(expired 语义与 Flask 同)。"""
"""会话保活:返回最新次数;超限/失效给 401(expired 语义不变)。"""
if user is None:
return error('登录已失效', 401, error_code='unauthorized')
row = await repo.get_user_by_id(user.id)
+2 -2
View File
@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""认证状态端点(FastAPI 栈)——与 Flask /auth/me 响应契约逐字段对齐。
"""认证状态端点。
- GET/POST /api/v1/auth/me ↔ Flask /auth/me
· user:站点用户公开字段(未登录为 None)
@@ -28,7 +28,7 @@ async def auth_me(
user: Annotated[SiteUser | None, Depends(current_user)],
request: Request,
):
"""当前会话与站点公开配置(未登录也返回,与 Flask 同口径)。"""
"""当前会话与站点公开配置(未登录也返回)。"""
await ratelimit.guard(request, bucket='free', limit=30, window=60)
try:
ann = await repo.get_announcement()
+3 -3
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""结果交付端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""结果交付端点。
已迁移:
端点:
- POST/GET /api/v1/download/result ↔ Flask /download/result(单曲)
- POST/GET /api/v1/download/batch_zip/result ↔ Flask /download/batch_zip/result(批量 ZIP)
语义对齐要点:
- 任务归属:仅任务本人的站点账号可领取(非本人 403)
- 状态语义:进行中 202 / 暂停 200+提示 / 取消 200+原因 / 失败 500+原因 /
文件缺失 404(与 Flask 分支逐一对应)
文件缺失 404(与历史分支逐一对应)
- 交付物:FileResponse 流式发送;Cache-Control: no-store;
X-Download-Filename(URL 编码,供前端取回原始文件名)
- 文件清理仍由监控线程按 35 分钟保留期统一回收(交付请求不删文件)
+5 -5
View File
@@ -1,15 +1,15 @@
# -*- coding: utf-8 -*-
"""下载提交端点(FastAPI 栈)——与 Flask 侧逐字段对齐。
"""下载提交端点。
已迁移:
端点:
- POST /api/v1/download ↔ Flask /download(计费 1 次;file/json 两模式)
- POST /api/v1/download/batch_zip ↔ Flask /download/batch_zip(按去重歌曲数计费)
- POST /api/v1/podcast/download ↔ Flask /podcast/download(计费 1 次)
- POST /api/v1/podcast/download/batch_zip ↔ Flask /podcast/download/batch_zip
计费:经 web.billing.require_billing(预扣+开账同事务 / 失败只退 open 行),
任务登记与启动复用 web.tasks_registry(与 main._register_download_task 同口径);
worker 仍复用 main 的既有实现(无 flask 依赖),故账本与交付语义不变。
任务登记与启动由 web.tasks_registry 承担;
worker 复用 services 层既有实现,账本与交付语义不变。
"""
import asyncio
import logging
@@ -283,7 +283,7 @@ async def download_batch_zip(user: Annotated[SiteUser | None, Depends(current_us
# ============ 播客单集下载 ============
def _podcast_billing_count(data: dict[str, Any]) -> int:
"""播客打包计费(与 Flask _podcast_billing_count 同口径)。"""
"""播客打包计费。"""
if data.get('radio_id'):
return 1
limit = _safe_int(data.get('limit', 200) or 200, 200)
+4 -7
View File
@@ -1,11 +1,8 @@
# -*- coding: utf-8 -*-
"""首页与静态资源(FastAPI 栈)。
"""首页与静态资源。
- GET / 首页:渲染同一份 templates/index.html,禁止缓存(与 Flask 一致)
- GET / 首页:渲染 templates/index.html,禁止缓存
- 静态资源由 Starlette StaticFiles 挂载 /static(见 web/app.py)
说明:首页在 Flask fallback 模式下由 Flask 兜底提供;本端点用于「关闭 Flask 后」
FastAPI 独立提供首页(迁移完成的形态)。两者渲染的是同一模板,故内容一致。
"""
from pathlib import Path
@@ -21,8 +18,8 @@ _TEMPLATE = Path(__file__).resolve().parent.parent.parent / 'templates' / 'index
async def index(request: Request) -> HTMLResponse:
"""首页(禁止缓存,确保前端更新即时生效)。
用 Jinja2 以 Flask 相同的默认口径渲染(keep_trailing_newline=False),
使输出与 Flask render_template 逐字节一致。
用 Jinja2 以与历史模板渲染一致的默认口径(keep_trailing_newline=False),
使输出逐字节不变。
"""
import jinja2
+7 -7
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""音乐端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""音乐端点。
已迁移:
端点:
- 免费解析类(需登录,不扣次数):
POST/GET /api/v1/toplist ↔ Flask /toplist
POST/GET /api/v1/artist_songs ↔ Flask /artist_songs
@@ -11,7 +11,7 @@
POST/GET /api/v1/playlist ↔ Flask /playlist
POST/GET /api/v1/song ↔ Flask /song(四种 type:url/name/lyric/json)
参数获取沿用 Flask 口径:GET query / POST json / POST form 三路兼容。
参数获取口径:GET query / POST json / POST form 三路兼容。
上游调用走 music_api 的**异步**方法(原生并发,无同步桥)。
"""
import asyncio
@@ -62,7 +62,7 @@ def _nick_lock() -> asyncio.Lock:
async def mask_server_nickname(nick: str, user: SiteUser) -> str:
"""非管理员响应中把服务器账号昵称替换为站点名(与 Flask _mask_server_nickname 同口径)。
"""非管理员响应中把服务器账号昵称替换为站点名。
- 管理员本人可见真实昵称;
- 缓存 10 分钟内复用;上游拿不到时回落站点库里的最后已知昵称继续遮蔽。
@@ -175,7 +175,7 @@ async def search(user: LoginUser, request: Request):
except APIException:
return error('服务暂时不可用,请稍后再试', 500)
# 向后兼容字段 + 服务器昵称遮蔽(与 Flask 侧同口径)
# 向后兼容字段 + 服务器昵称遮蔽
if result:
for song in result:
if song.get('type', 'song') == 'song' and 'artists' in song:
@@ -191,7 +191,7 @@ async def search(user: LoginUser, request: Request):
# ============ 个人/全局账号 Cookie ============
def _global_music_u() -> str:
"""全局账号 MUSIC_U(5 分钟缓存,与 Flask main._global_music_u 同源同缓存)。"""
"""全局账号 MUSIC_U(5 分钟缓存)。"""
now = time.time()
if GLOBAL_MUSIC_U_CACHE['v'] is None or now - GLOBAL_MUSIC_U_CACHE['ts'] > 300:
try:
@@ -209,7 +209,7 @@ async def _resolve_user_cookies(
"""解析个人账号 Cookie(浏览器传入的 user_cookie,不落服务器)。
全局账号(cookie.txt)零个人身份权限:浏览器提交的 MUSIC_U 若与全局账号一致
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计(与 Flask 同口径)。
(全局 Cookie 泄露被当个人 Cookie 冒用),一律拒绝并记审计。
"""
ck = media.parse_cookie_str(data.get('user_cookie'))
mu = (ck.get('MUSIC_U') or '').strip()
+4 -4
View File
@@ -1,14 +1,14 @@
# -*- coding: utf-8 -*-
"""播客端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""播客端点。
已迁移(免费解析类,需登录,不扣次数):
端点(免费解析类,需登录,不扣次数):
- POST/GET /api/v1/podcast/parse ↔ Flask /podcast/parse
- POST/GET /api/v1/podcast/programs ↔ Flask /podcast/programs
- POST/GET /api/v1/podcast/search ↔ Flask /podcast/search
- POST/GET /api/v1/podcast/hot ↔ Flask /podcast/hot
说明:podcast_api 仍是同步模块(requests),此处以 asyncio.to_thread 包装,
让阻塞的上游调用不占用事件循环——行为与 Flask 侧一致,且不阻塞其它请求。
让阻塞的上游调用不占用事件循环——行为不变,且不阻塞其它请求。
"""
import asyncio
from typing import Annotated
@@ -164,6 +164,6 @@ async def podcast_hot(_user: LoginUser, request: Request):
categories = await asyncio.to_thread(podcast_api.get_categories)
except APIException:
categories = []
# 满页视为可能还有更多(与 Flask 同口径)
# 满页视为可能还有更多
return success({'radios': radios, 'categories': categories,
'has_more': len(radios) >= limit}, '获取热门播客成功')
+3 -3
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""注册 / 找回密码 / 卡密 / 验证码(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
"""注册 / 找回密码 / 卡密 / 验证码。
已迁移:
端点:
- POST /api/v1/auth/send_code ↔ Flask /auth/send_code
- POST /api/v1/auth/register ↔ Flask /auth/register
- POST /api/v1/auth/trial ↔ Flask /auth/trial
@@ -10,7 +10,7 @@
说明:
- 同步的 auth_system 调用(SQLite 事务、邮件 SMTP、PBKDF2)经 asyncio.to_thread 执行,
不阻塞事件循环;行为与 Flask 完全一致。
不阻塞事件循环;行为与历史实现完全一致。
- 发码 IP 限流复用 web.auth_state 的模块级状态 `_SEND_CODE_IP`。
- 失败锁定/验证码消费等安全口径逐字保留。
"""
+4 -4
View File
@@ -1,11 +1,11 @@
# -*- coding: utf-8 -*-
"""站点数据端点(FastAPI 栈):走 core.db 异步 SQLite(读并发/写串行/WAL)。
"""站点数据端点:走 core.db 异步 SQLite(读并发/写串行/WAL)。
这些是**新增**的 `/api/v1` 只读端点,用于验证异步读写路径与共享同一份站点库;
不替代、不影响 Flask 侧既有端点。鉴权端点(需登录/管理员)待会话互通层就绪后再迁移。
这些是 `/api/v1` 下的只读端点,走异步读写路径并共享同一份站点库;
鉴权端点(需登录/管理员)由 web.deps 统一把关。
注意:本模块只做读(与 announcements/settings 这些低风险资源对应),
写操作仍归 auth_system 的事务口径,避免迁移期两套写路径互相踩踏。
写操作统一归 auth_system 的事务口径,避免两套写路径互相踩踏。
"""
from fastapi import APIRouter
+4 -4
View File
@@ -1,12 +1,12 @@
# -*- coding: utf-8 -*-
"""音频流代理(FastAPI 栈)——与 Flask /audio_stream 行为逐项对齐。
"""音频流代理。
要点(对齐 Flask 实现):
- 签名校验:token 由 core.security 校验(密钥同源,两栈 token 互认);失效 403。
要点:
- 签名校验:token 由 core.security 校验(密钥同源);失效 403。
- HEAD 无正文:直接 200 + Accept-Ranges,不请求上游。
- Range 透传:客户端 Range 原样转发上游,206/Content-Range 原样回传(拖动进度可用)。
- 编码透明:上游若带 Content-Encoding(aiohttp 会解压),不得沿用压缩前的
Content-Length/Content-Range(否则头体不一致,同 Flask F92)。
Content-Length/Content-Range(否则头体不一致)。
- 流式转发:16KB 分块,不整段入内存;下游中断时 finally 关闭上游响应。
- Cache-Control: no-store。
"""
+6 -6
View File
@@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
"""系统端点(迁移样板):/health、/api/info。
"""系统端点:/health、/api/info。
/flask 侧的这两个端点无鉴权、无状态、无任务依赖,是最安全的迁移起点。
此处实现与 main.py 的 health_check / api_info **响应契约逐字段对齐**,
用于验证双框架一致性(见 .zcode/refactor/parity.py)。
两个端点无鉴权、无状态、无任务依赖。
响应契约与历史快照逐字段对齐,
由 tests/golden/responses.json 兜住回归。
"""
import sys
@@ -16,7 +16,7 @@ from web.response import error, success
router = APIRouter()
# 与 main.py api_info 的 endpoints 表保持一致(迁移期以 Flask 侧为准,逐字对齐)
# 与 api_info 的 endpoints 表保持一致(逐字对齐历史契约)
_ENDPOINTS = {
'/health': 'GET - 健康检查',
'/song': 'GET/POST - 获取歌曲信息',
@@ -51,7 +51,7 @@ def health_check():
'timestamp': int(time.time()) if 'time' in sys.modules else None,
'cookie_status': cookie_status,
'cookie_count': len(cookies),
'downloads_dir': 'configured', # R5-P3:不向匿名访客回显服务器绝对路径
'downloads_dir': 'configured', # 不向匿名访客回显服务器绝对路径
'version': APP_VERSION,
}
return success(health_info, "API服务运行正常")
+6 -6
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""任务状态端点(FastAPI 栈)——与 Flask 侧同名端点响应契约逐字段对齐。
"""任务状态端点。
已迁移:
端点:
- POST/GET /api/v1/topen ↔ Flask /api/topen(请求统计,无鉴权)
- POST/GET /api/v1/queue ↔ Flask /api/queue(排队状态,无鉴权)
- POST/GET /api/v1/batch_progress ↔ Flask /api/batch_progress(需登录,仅本人任务)
@@ -9,8 +9,8 @@
- POST/GET /api/v1/task/heartbeat ↔ Flask /api/task/heartbeat(需登录)
- POST/GET /api/v1/task/list ↔ Flask /api/task/list(需登录)
这些端点读写 core.state 的共享任务表(与 Flask 同一批对象),因此两栈在迁移期
看到的是同一份任务状态——这是「单进程双框架」能成立的关键。
这些端点读写 core.state 的共享任务表(与任务执行侧同一批对象),因此
看到的是同一份任务状态。
"""
import time
from typing import Annotated, Any
@@ -41,7 +41,7 @@ LoginUser = Annotated[SiteUser, Depends(require_login)]
def _touch_device(device_id: str) -> None:
"""记录设备心跳(与 Flask _touch_device 同口径,共享 DEVICE_LAST_SEEN)。"""
"""记录设备心跳(与 core.state 的 DEVICE_LAST_SEEN 共享)。"""
device_id = str(device_id or '')[:64]
if not device_id:
return
@@ -57,7 +57,7 @@ def _owned_by(task: dict[str, Any], user: SiteUser) -> bool:
def _semaphores():
"""批量打包/下载信号量(Flask 侧对象,懒取以共享同一计数)。"""
"""批量打包/下载信号量(懒取,与 core.state 中的同一对象共享计数)。"""
import services.runtime as services
return services.BATCH_ZIP_SEM, services.api_service.downloader._download_semaphore
+2 -2
View File
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""个人账号与公开播放端点(FastAPI 栈)——与 Flask 侧响应契约逐字段对齐。
"""个人账号与公开播放端点。
已迁移:
端点:
- POST /api/v1/user/qr/create ↔ Flask /user/qr/create(扫码登录二维码 SVG)
- POST /api/v1/user/qr/check ↔ Flask /user/qr/check(轮询登录状态)
- POST /api/v1/user/status ↔ Flask /user/status