- 专辑搜索静态路由优先注册,避免被 /album/{source}/{id}/{page} 捕获
- mg 流改为异步背压队列,慢客户端不再 QueueFull 悬挂,错误中断响应
- 限流移到鉴权外层,401 爆破计入限流
- 酷我 token 重试移出锁,修复 asyncio.Lock 递归死锁
- 补 CORS、5sing 音质、parse _kind、wy 首次登录 cookie、stream 签名溢出
- mg 流直连、lifespan 关闭 aiohttp 会话、cookie 登录路由与 auth_store 健壮性
- 测试改用 aiohttp + 进程内 uvicorn,移除 httpx/TestClient
- 更新 start.bat 与文档
433 lines
15 KiB
Python
433 lines
15 KiB
Python
"""登录路由 —— wy 扫码/手机号/cookie,kg 扫码/短信/密码/token,tx 扫码/cookie,
|
||
xm/me cookie,lr 账密,bili 扫码;凭据持久化于 data/auth.json。
|
||
|
||
成功返回 `ok(data)`({ code: 0, data });可预期失败抛 ApiError,其它异常按 502 上报
|
||
(与原实现 `fail(reply, err)` 的 `err.status ?? 502` 一致)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from app.http.api_error import ApiError, not_found, upstream
|
||
from app.http.envelope import ok
|
||
from app.routes.refresh import refresh_all
|
||
from app.sdk import auth as auth_mod
|
||
from app.sdk.auth_store import clear_credentials, get_credentials, get_tx_auth, set_credentials
|
||
from fastapi import APIRouter, Body, Path
|
||
|
||
router = APIRouter()
|
||
|
||
LOGIN_TAG = "登录"
|
||
|
||
|
||
async def _call(fn: Any, *args: Any, **kwargs: Any) -> Any:
|
||
"""调用平台登录实现并包成信封;ApiError 原样抛(保留状态码),其余转 502。"""
|
||
try:
|
||
return ok(await fn(*args, **kwargs))
|
||
except ApiError:
|
||
raise
|
||
except NotImplementedError as err:
|
||
raise ApiError(501, f"该登录方式尚未移植:{err}") from err
|
||
except Exception as err:
|
||
raise upstream(str(err)) from err
|
||
|
||
|
||
def _body(description: str) -> Any:
|
||
# embed=True:与 TS 契约一致,所有登录接口都以 JSON 对象传参({"key": ...}),
|
||
# 而不是把单个参数当成裸 JSON 值。多参数时 FastAPI 本就自动嵌套,行为不变。
|
||
return Body(..., embed=True, description=description)
|
||
|
||
|
||
# ---------- 网易云 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/wy/qr",
|
||
tags=[LOGIN_TAG],
|
||
summary="获取网易云扫码登录二维码",
|
||
description="用网易云 App 扫码后轮询 /login/wy/qr/check 完成登录。返回 qrimg(data URL)可直接展示。",
|
||
)
|
||
async def wy_qr() -> Any:
|
||
return await _call(auth_mod.wy.qr_key)
|
||
|
||
|
||
@router.post(
|
||
"/login/wy/qr/check",
|
||
tags=[LOGIN_TAG],
|
||
summary="轮询网易云扫码状态",
|
||
description="code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据并返回账号资料)。",
|
||
)
|
||
async def wy_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||
return await _call(auth_mod.wy.qr_check, key)
|
||
|
||
|
||
@router.post(
|
||
"/login/wy/cellphone",
|
||
tags=[LOGIN_TAG],
|
||
summary="网易云手机号登录",
|
||
description="password(明文,服务端 MD5 后提交)与 captcha(短信验证码)二选一。",
|
||
)
|
||
async def wy_cellphone(
|
||
phone: str = _body("手机号"),
|
||
password: str | None = Body(None, description="明文密码"),
|
||
captcha: str | None = Body(None, description="短信验证码"),
|
||
countrycode: str = Body("86", description="国家码"),
|
||
) -> Any:
|
||
return await _call(auth_mod.wy.cellphone_login, phone, password, captcha, countrycode)
|
||
|
||
|
||
@router.post(
|
||
"/login/wy",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定网易云 cookie",
|
||
description="cookie 从已登录的 music.163.com 浏览器请求头复制(须含 MUSIC_U 字段)。"
|
||
"绑定前服务端实测校验有效性(account/get),失效返回 401。无法自动续期,失效后需重新复制绑定。",
|
||
)
|
||
async def wy_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||
return await _call(auth_mod.wy.login_by_cookie, cookie)
|
||
|
||
|
||
@router.delete("/login/wy", tags=[LOGIN_TAG], summary="退出网易云登录")
|
||
async def wy_logout() -> Any:
|
||
clear_credentials("wy")
|
||
return ok({"logged": False})
|
||
|
||
|
||
# ---------- 酷狗 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/kg",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定酷狗 token",
|
||
description="token 从酷狗 App 抓包获取(login.token),服务端向用户中心验证后存储。"
|
||
"推荐改用 /login/kg/qr 扫码或 /login/kg/cellphone 短信验证码登录,无需抓包。",
|
||
)
|
||
async def kg_bind(token: str = _body("酷狗 token"), userid: str | int = _body("酷狗 userid")) -> Any:
|
||
return await _call(auth_mod.kg.login_by_token, token, userid)
|
||
|
||
|
||
@router.post("/login/kg/qr", tags=[LOGIN_TAG], summary="获取酷狗扫码登录二维码")
|
||
async def kg_qr() -> Any:
|
||
return await _call(auth_mod.kg.qr_key)
|
||
|
||
|
||
@router.post("/login/kg/qr/check", tags=[LOGIN_TAG], summary="轮询酷狗扫码状态")
|
||
async def kg_qr_check(key: str = _body("qrKey 返回的 key")) -> Any:
|
||
return await _call(auth_mod.kg.qr_check, key)
|
||
|
||
|
||
@router.post(
|
||
"/login/kg/captcha",
|
||
tags=[LOGIN_TAG],
|
||
summary="发送酷狗短信验证码",
|
||
description="向手机号发送登录验证码(有频控,勿频繁调用),随后用 /login/kg/cellphone 提交。",
|
||
)
|
||
async def kg_captcha(mobile: str = _body("手机号")) -> Any:
|
||
return await _call(auth_mod.kg.send_captcha, mobile)
|
||
|
||
|
||
@router.post("/login/kg/cellphone", tags=[LOGIN_TAG], summary="酷狗手机验证码登录")
|
||
async def kg_cellphone(mobile: str = _body("手机号"), code: str = _body("短信验证码")) -> Any:
|
||
return await _call(auth_mod.kg.cellphone_login, mobile, code)
|
||
|
||
|
||
@router.post(
|
||
"/login/kg/password",
|
||
tags=[LOGIN_TAG],
|
||
summary="酷狗账号密码登录",
|
||
description="触发 20028 风控时返回 needVerify 挑战(含 eventid、验证类型与 sid/edt 指纹):"
|
||
"滑块类需浏览器完成图形验证后调 /login/kg/verify 再重试;短信类(v_type=32)先提交短信码再带 code 重试。",
|
||
)
|
||
async def kg_password(
|
||
username: str = _body("账号"),
|
||
password: str = _body("密码"),
|
||
code: str | None = Body(None, description="短信验证码(20028 短信验证通过后重试登录时携带)"),
|
||
) -> Any:
|
||
return await _call(auth_mod.kg.password_login, username, password, code)
|
||
|
||
|
||
@router.post("/login/kg/verify", tags=[LOGIN_TAG], summary="提交酷狗风控验证结果")
|
||
async def kg_verify(
|
||
eventid: str = _body("needVerify 返回的 eventid"),
|
||
v_type: int = _body("验证类型(needVerify 返回的 v_type)"),
|
||
verifycode: str = _body("图形验证码凭证或短信验证码"),
|
||
sid: str | None = Body(None, description="行为指纹 sid(省略时服务端生成)"),
|
||
edt: str | None = Body(None, description="行为指纹 edt(省略时服务端生成)"),
|
||
) -> Any:
|
||
return await _call(auth_mod.kg.verify_user_info, eventid, v_type, verifycode, sid, edt)
|
||
|
||
|
||
@router.delete("/login/kg", tags=[LOGIN_TAG], summary="退出酷狗登录")
|
||
async def kg_logout() -> Any:
|
||
clear_credentials("kg")
|
||
return ok({"logged": False})
|
||
|
||
|
||
# ---------- QQ 音乐 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/tx/qr",
|
||
tags=[LOGIN_TAG],
|
||
summary="获取 QQ 音乐扫码登录二维码",
|
||
description="返回 qrimg(data URL)可直接展示,qrsig 需原样回传。",
|
||
)
|
||
async def tx_qr() -> Any:
|
||
return await _call(auth_mod.tx.qr_show)
|
||
|
||
|
||
@router.post("/login/tx/qr/check", tags=[LOGIN_TAG], summary="轮询 QQ 音乐扫码状态")
|
||
async def tx_qr_check(qrsig: str = _body("qrShow 返回的 qrsig")) -> Any:
|
||
return await _call(auth_mod.tx.qr_check, qrsig)
|
||
|
||
|
||
@router.post(
|
||
"/login/tx",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定 QQ 音乐 cookie",
|
||
description="cookie 从已登录的 y.qq.com 浏览器请求头复制(需含 uin 与 qm_keyst 字段),"
|
||
"用于解锁 tx 取流的 320k/无损音质。",
|
||
)
|
||
async def tx_bind(
|
||
cookie: str = _body("浏览器 cookie"),
|
||
openId: str | None = Body(None, description="App 抓包的 openId(用于自动续期)"),
|
||
accessToken: str | None = Body(None, description="App 抓包的 accessToken"),
|
||
refreshToken: str | None = Body(None, description="App 抓包的 refreshToken"),
|
||
refreshKey: str | None = Body(None, description="App 抓包的 refreshKey"),
|
||
) -> Any:
|
||
import re
|
||
|
||
match = re.search(r"uin=(\d+)", cookie)
|
||
if not match or not re.search(r"qm_keyst=\S+", cookie):
|
||
raise ApiError(422, "cookie 需包含 uin 与 qm_keyst 字段")
|
||
uin = match.group(1)
|
||
set_credentials(
|
||
"tx",
|
||
{
|
||
"cookie": cookie,
|
||
"uin": uin,
|
||
"openId": openId,
|
||
"accessToken": accessToken,
|
||
"refreshToken": refreshToken,
|
||
"refreshKey": refreshKey,
|
||
},
|
||
)
|
||
return ok({"logged": True, "uin": uin})
|
||
|
||
|
||
@router.delete("/login/tx", tags=[LOGIN_TAG], summary="退出 QQ 音乐登录")
|
||
async def tx_logout() -> Any:
|
||
clear_credentials("tx")
|
||
return ok({"logged": False})
|
||
|
||
|
||
# ---------- 喜马拉雅 / 猫耳 / 懒人听书 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/xm",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定喜马拉雅 cookie",
|
||
description="cookie 从已登录的 www.ximalaya.com 浏览器请求头复制(须含 1&_token 字段)。",
|
||
)
|
||
async def xm_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||
import re
|
||
|
||
if not re.search(r"1&_token=\S+", cookie):
|
||
raise ApiError(422, "cookie 需包含 1&_token 字段")
|
||
set_credentials("xm", {"cookie": cookie})
|
||
return ok({"logged": True})
|
||
|
||
|
||
@router.delete("/login/xm", tags=[LOGIN_TAG], summary="退出喜马拉雅登录")
|
||
async def xm_logout() -> Any:
|
||
clear_credentials("xm")
|
||
return ok({"logged": False})
|
||
|
||
|
||
@router.post(
|
||
"/login/me",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定猫耳FM token",
|
||
description="cookie 从已登录的 m.missevan.com 浏览器请求头或猫耳 App 抓包复制(须含 token= 字段)。",
|
||
)
|
||
async def me_bind(cookie: str = _body("浏览器 cookie")) -> Any:
|
||
import re
|
||
|
||
if not re.search(r"(?:^|;\s*)token=\S+", cookie):
|
||
raise ApiError(422, "cookie 需包含 token 字段")
|
||
set_credentials("me", {"cookie": cookie})
|
||
return ok({"logged": True})
|
||
|
||
|
||
@router.delete("/login/me", tags=[LOGIN_TAG], summary="退出猫耳FM登录")
|
||
async def me_logout() -> Any:
|
||
clear_credentials("me")
|
||
return ok({"logged": False})
|
||
|
||
|
||
@router.post(
|
||
"/login/lr",
|
||
tags=[LOGIN_TAG],
|
||
summary="懒人听书账号登录",
|
||
description="m.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化,"
|
||
"此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。",
|
||
)
|
||
async def lr_login(
|
||
account: str = _body("账号(手机号)"), password: str = _body("密码(明文传入,服务端自行编码)")
|
||
) -> Any:
|
||
return await _call(auth_mod.lr.login, account, password)
|
||
|
||
|
||
@router.delete("/login/lr", tags=[LOGIN_TAG], summary="退出懒人听书登录")
|
||
async def lr_logout() -> Any:
|
||
clear_credentials("lr")
|
||
return ok({"logged": False})
|
||
|
||
|
||
# ---------- 哔哩哔哩 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/bili/qr",
|
||
tags=[LOGIN_TAG],
|
||
summary="获取哔哩哔哩扫码登录二维码",
|
||
description="用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL。",
|
||
)
|
||
async def bili_qr() -> Any:
|
||
return await _call(auth_mod.bili.create_qr_login)
|
||
|
||
|
||
@router.post(
|
||
"/login/bili/qr/check",
|
||
tags=[LOGIN_TAG],
|
||
summary="轮询哔哩哔哩扫码状态",
|
||
description="status:waiting / scanned / success(自动保存凭据)/ expired / failed。"
|
||
"code 为映射后的状态码:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(与其它平台一致)。",
|
||
)
|
||
async def bili_qr_check(key: str = _body("createQRLogin 返回的 key")) -> Any:
|
||
return await _call(auth_mod.bili.check_qr_login, key)
|
||
|
||
|
||
@router.delete("/login/bili", tags=[LOGIN_TAG], summary="退出哔哩哔哩登录")
|
||
async def bili_logout() -> Any:
|
||
clear_credentials("bili")
|
||
return ok({"logged": False})
|
||
|
||
|
||
# ---------- 通用 ----------
|
||
|
||
|
||
@router.post(
|
||
"/login/refresh",
|
||
tags=[LOGIN_TAG],
|
||
summary="手动触发登录态续期",
|
||
description="对已登录的 kg/tx 执行 token/cookie 续期(与自动任务同一逻辑)。"
|
||
"未登录的平台跳过;失败的平台在对应字段返回 error,不影响其他平台。",
|
||
)
|
||
async def login_refresh() -> Any:
|
||
return ok(await refresh_all())
|
||
|
||
|
||
# 这些平台没有独立登录协议,只能从浏览器/App 复制 cookie(README 平台表已声明 cookie 登录)。
|
||
# 白名单限定,避免动态路由把任意平台写进凭据文件。
|
||
_COOKIE_LOGIN_SOURCES = {"mg", "bd", "joox", "apple", "5sing"}
|
||
|
||
|
||
@router.post(
|
||
"/login/{source}",
|
||
tags=[LOGIN_TAG],
|
||
summary="绑定平台 cookie",
|
||
description="mg/bd/joox/apple/5sing 用:从浏览器或 App 复制完整 cookie 字符串并持久化。"
|
||
"apple 需包含 media-user-token;joox 通常需 wmid/session_key。服务端不做在线校验。",
|
||
)
|
||
async def cookie_bind(
|
||
source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)", examples=["bd"]),
|
||
cookie: str = Body(..., embed=True, description="完整 cookie 字符串"),
|
||
) -> Any:
|
||
if source not in _COOKIE_LOGIN_SOURCES:
|
||
raise not_found(f"不支持的 cookie 登录平台: {source}")
|
||
value = str(cookie or "").strip()
|
||
if not value:
|
||
raise ApiError(422, "cookie 不能为空")
|
||
set_credentials(source, {"cookie": value})
|
||
return ok({"logged": True})
|
||
|
||
|
||
@router.delete(
|
||
"/login/{source}",
|
||
tags=[LOGIN_TAG],
|
||
summary="退出平台登录(cookie 平台)",
|
||
)
|
||
async def cookie_logout(source: str = Path(..., description="平台 id(mg/bd/joox/apple/5sing)")) -> Any:
|
||
if source not in _COOKIE_LOGIN_SOURCES:
|
||
raise not_found(f"不支持的 cookie 登录平台: {source}")
|
||
clear_credentials(source)
|
||
return ok({"logged": False})
|
||
|
||
|
||
def _stored_status(package: str) -> dict[str, Any]:
|
||
"""按已存凭据判断登录态(xm/me/bd/joox/apple/5sing 用)。"""
|
||
cred = get_credentials(package)
|
||
if not cred:
|
||
return {"logged": False}
|
||
return {"logged": True, "updatedAt": cred.get("updatedAt")}
|
||
|
||
|
||
@router.get(
|
||
"/login/status",
|
||
tags=[LOGIN_TAG],
|
||
summary="查看登录状态",
|
||
description="wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据"
|
||
"(含 VIP 等级与最近更新时间),xm/me/lr/bd/joox/apple/5sing 为已存凭据。",
|
||
)
|
||
async def login_status() -> Any:
|
||
|
||
async def safe_wy() -> dict[str, Any]:
|
||
try:
|
||
result = await auth_mod.wy.status()
|
||
return {"logged": True, **result} if result else {"logged": False}
|
||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||
return {"logged": False}
|
||
|
||
async def safe_kg() -> dict[str, Any]:
|
||
cred = get_credentials("kg")
|
||
if not cred or not cred.get("token"):
|
||
return {"logged": False}
|
||
try:
|
||
result = await auth_mod.kg.status()
|
||
except Exception: # noqa: BLE001 - 未登录/失效都按未登录处理
|
||
result = None
|
||
payload = {**(result or {}), "updatedAt": cred.get("updatedAt")}
|
||
return {"logged": True, **payload}
|
||
|
||
wy_result = await safe_wy()
|
||
tx = get_tx_auth()
|
||
tx_cred = get_credentials("tx") or {}
|
||
return ok(
|
||
{
|
||
"wy": wy_result,
|
||
"kg": await safe_kg(),
|
||
"tx": (
|
||
{
|
||
"logged": True,
|
||
"uin": tx["uin"],
|
||
"vipType": tx_cred.get("vipType", None),
|
||
"updatedAt": tx_cred.get("updatedAt"),
|
||
}
|
||
if tx
|
||
else {"logged": False}
|
||
),
|
||
"xm": _stored_status("xm"),
|
||
"me": _stored_status("me"),
|
||
"lr": _stored_status("lr"),
|
||
"bili": _stored_status("bili"),
|
||
"bd": _stored_status("bd"),
|
||
"joox": _stored_status("joox"),
|
||
"apple": _stored_status("apple"),
|
||
"5sing": _stored_status("5sing"),
|
||
}
|
||
)
|