feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+1
View File
@@ -0,0 +1 @@
"""服务端横切能力:错误、鉴权、限流、流签名、咪咕解密代理。"""
+29
View File
@@ -0,0 +1,29 @@
"""API 业务错误 —— 路由处理中抛出,由全局异常处理器转换为对应 HTTP 状态码的 JSON 响应。"""
from __future__ import annotations
from typing import Any
class ApiError(Exception):
"""业务错误:status 同时作为响应体中的 code。"""
def __init__(self, status: int, msg: str, detail: Any = None) -> None:
super().__init__(msg)
self.status = status
self.code = status
self.detail = detail
def not_found(msg: str, detail: Any = None) -> ApiError:
"""资源不存在(未知路由 / 不支持的平台)。"""
return ApiError(404, msg, detail)
def upstream(msg: str, detail: Any = None) -> ApiError:
"""上游平台请求失败。"""
return ApiError(502, msg, detail)
def bad_request(msg: str, detail: Any = None) -> ApiError:
return ApiError(400, msg, detail)
+55
View File
@@ -0,0 +1,55 @@
"""全局调用秘钥鉴权 —— AUTH_KEY 环境变量启用(未设置则完全不鉴权)。
验证头统一为 X-Server-Key;全部路径(含文档页/健康检查/404)都要求秘钥,
未授权请求一律 401,避免被扫描器区分出接口存在性(404/401 差异)。
CORS 预检(OPTIONS)无响应体、不泄露内容,豁免。
/stream/* 流代理例外:播放器/下载器直接 GET 媒体地址、无法携带自定义头,
改用短时签名(?exp=&sig=,见 stream_sign.py)——不落裸秘钥到 URL/日志,
签名泄露仅能在时效窗口内拉当条流;能带头的客户端仍可用 X-Server-Key。
"""
from __future__ import annotations
import hmac
from starlette.requests import Request
from starlette.responses import JSONResponse
from app.http.stream_sign import verify_stream_sign
def _safe_equal(a: str, b: str) -> bool:
return hmac.compare_digest(a.encode(), b.encode())
async def auth_gate(request: Request, call_next):
if request.method == "OPTIONS":
return await call_next(request)
auth_key = getattr(request.app.state, "auth_key", "") or ""
if not auth_key: # 未配置秘钥:完全不鉴权
return await call_next(request)
pathname = request.url.path
header = request.headers.get("x-server-key", "")
if pathname.startswith("/stream/"):
query = request.query_params
ok = (header and _safe_equal(header, auth_key)) or (
not header and verify_stream_sign(auth_key, pathname, query.get("exp"), query.get("sig"))
)
if ok:
return await call_next(request)
return JSONResponse(
{"code": 401, "msg": "无效或缺少调用秘钥:X-Server-Key 头,或 /url 返回流地址自带的 exp/sig 签名"},
status_code=401,
)
if not header or not _safe_equal(header, auth_key):
# 统一 401 文案(不区分路径存在与否,防扫描探测)
return JSONResponse(
{"code": 401, "msg": "无效或缺少调用秘钥:请在 X-Server-Key 请求头携带"},
status_code=401,
)
return await call_next(request)
+22
View File
@@ -0,0 +1,22 @@
"""统一成功信封 { code: 0, data }。
独立成模块是为了避免 routes → main → routes 的循环导入。
"""
from __future__ import annotations
import json
from typing import Any
from fastapi.responses import JSONResponse
class Envelope(JSONResponse):
"""成功响应:{ "code": 0, "data": <payload> }。"""
def render(self, content: Any) -> bytes:
return json.dumps({"code": 0, "data": content}, ensure_ascii=False).encode("utf-8")
def ok(data: Any, status_code: int = 200) -> Envelope:
return Envelope(data, status_code=status_code)
+195
View File
@@ -0,0 +1,195 @@
"""咪咕加密流 —— 票据登记与 Z3D 流式解密(对应原 src/http/mg-stream.ts + sdk/mg/util.ts 解密部分)。
统一入口为 /stream/:source/:id,source=mg 时 id 即此处签发的短票据。
不把上游 URL 塞进播放地址(path 参数限长,且 URL 含时效签名与中文路径),
票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
Z3D 解密算法(与原始 TS 一致):
streamKey = MD5(AES128CBC(AndroidAppKey, ENCRYPTED_APP_CODE) + fileKey) 的大写十六进制(32 字节 ASCII)
plain[i] = (cipher[i] - streamKey[i % 32]) & 0xFF
密文可能以 3 字节 `Z3D` 魔数开头,需先剥离;已解密的数据(音频魔数开头)原样透传。
"""
from __future__ import annotations
import hashlib
import time
import uuid
from collections.abc import Callable
from dataclasses import dataclass
from urllib.parse import urljoin
import aiohttp
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
TTL_SECONDS = 10 * 60
STREAM_CACHE_LIMIT = 200
MIGU_STREAM_UA = "Android_migu/7.41.13 okhttp/3.12.13"
# 咪咕 Android 客户端的 app key 与加密后的 app code(服务端下发,非机密凭据)
_ANDROID_APP_KEY = b"308202333082019c"
_ENCRYPTED_APP_CODE = bytes(
[
0xAE, 0xEA, 0xC7, 0x17, 0x05, 0xA6, 0x89, 0x55,
0xA9, 0x31, 0xA6, 0xFE, 0x01, 0xE7, 0x28, 0xFF,
0xF6, 0x49, 0x71, 0xE6, 0x1C, 0xEA, 0xF0, 0x0A,
0xE3, 0xEA, 0x43, 0xC3, 0xE0, 0x58, 0x33, 0x83,
]
) # fmt: skip
_Z3D_IV = bytes(range(16))
@dataclass
class MiguStreamTicket:
url: str
file_key: str
created_at: float
_tickets: dict[str, MiguStreamTicket] = {}
def _prune() -> None:
now = time.time()
for key in [k for k, t in _tickets.items() if now - t.created_at > TTL_SECONDS]:
_tickets.pop(key, None)
# 超出容量时按插入顺序淘汰最旧票据
while len(_tickets) > STREAM_CACHE_LIMIT:
oldest = next(iter(_tickets), None)
if oldest is None:
break
_tickets.pop(oldest, None)
def register_migu_stream(url: str, file_key: str) -> str:
"""登记一条加密流,返回可直接放入 /stream/mg/:id 的短票据 id。"""
_prune()
ticket_id = uuid.uuid4().hex
_tickets[ticket_id] = MiguStreamTicket(url=url, file_key=file_key, created_at=time.time())
return ticket_id
def take_migu_stream(ticket_id: str) -> MiguStreamTicket | None:
"""取出票据(过期返回 None)。"""
ticket = _tickets.get(ticket_id)
if not ticket:
return None
if time.time() - ticket.created_at > TTL_SECONDS:
_tickets.pop(ticket_id, None)
return None
return ticket
def is_migu_audio_magic(data: bytes) -> bool:
"""判断是否明文音频:fLaC / RIFF-WAVE / OggS / ID3 / MP4(ftyp) / MP3 帧同步。"""
if len(data) < 4:
return False
head4 = data[:4]
if head4 in (b"fLaC", b"OggS"):
return True
if head4 == b"RIFF" and len(data) >= 12 and data[8:12] == b"WAVE":
return True
if data[:3] == b"ID3":
return True
if len(data) >= 12 and data[4:8] == b"ftyp":
return True
return data[0] == 0xFF and (data[1] & 0xE0) == 0xE0
def migu_z3d_stream_key(file_key: str) -> bytes:
"""streamKey = MD5(解密后的 appCode + fileKey) 的大写十六进制(32 字节 ASCII)。"""
key = str(file_key or "").strip()
if not key:
raise RuntimeError("空的 z3d fileKey")
# Go 的 CryptBlocks 按块解密不做 PKCS#7 去填充,这里同样禁用填充,否则尾块校验失败
decryptor = Cipher(algorithms.AES(_ANDROID_APP_KEY), modes.CBC(_Z3D_IV)).decryptor()
app_code = decryptor.update(_ENCRYPTED_APP_CODE) + decryptor.finalize()
digest = hashlib.md5(app_code + key.encode("utf-8")).hexdigest().upper()
return digest.encode("ascii")
def decrypt_z3d(data: bytes, file_key: str) -> bytes:
"""整段解密(非流式场景;已是明文则原样返回)。"""
if not data:
raise RuntimeError("空的 z3d 数据")
if is_migu_audio_magic(data):
return data
payload = data[3:] if data[:3] == b"Z3D" else data
if not payload:
raise RuntimeError("剥离 Z3D 魔数后无数据")
stream_key = migu_z3d_stream_key(file_key)
plain = bytes((byte - stream_key[index & 31]) & 0xFF for index, byte in enumerate(payload))
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
class Z3DStreamDecoder:
"""按块增量解密:跨块保持全局偏移,首块剥离 `Z3D` 魔数并校验结果。"""
def __init__(self, file_key: str) -> None:
self._stream_key = migu_z3d_stream_key(file_key)
self._offset = 0
self._pending = b"" # 首块不足 3 字节时暂存,用于判断 `Z3D` 魔数
self._checked = False
def feed(self, chunk: bytes) -> bytes:
if not chunk:
return b""
data = self._pending + chunk
self._pending = b""
if self._offset == 0 and len(data) < 3:
self._pending = data
return b""
if self._offset == 0 and data[:3] == b"Z3D":
data = data[3:]
base = self._offset
self._offset += len(data)
plain = bytes((byte - self._stream_key[(base + index) & 31]) & 0xFF for index, byte in enumerate(data))
if not self._checked and plain:
self._checked = True
if not is_migu_audio_magic(plain):
raise RuntimeError("z3d 解密后不是有效音频")
return plain
def migu_stream_content_type(url: str) -> str:
"""按扩展名推断明文的 content-type。"""
if ".flac" in url:
return "audio/flac"
if ".wav" in url:
return "audio/wav"
if ".m4a" in url:
return "audio/mp4"
return "audio/mpeg"
async def stream_decrypted(
url: str,
feed: Callable[[bytes], bytes],
on_chunk: Callable[[bytes], None],
*,
max_redirects: int = 3,
) -> None:
"""流式下载密流并逐块解密后交给 on_chunk。
整段音频可达上百 MB,因此按块处理而不读入内存;上游 URL 常带 302,
这里手动跟随(默认不跟随重定向,与请求层语义一致)。
"""
timeout = aiohttp.ClientTimeout(total=300, sock_read=60)
headers = {"User-Agent": MIGU_STREAM_UA, "Referer": "https://music.migu.cn/"}
current = url
for _ in range(max_redirects + 1):
async with aiohttp.ClientSession(timeout=timeout) as session:
async with session.get(current, headers=headers, allow_redirects=False) as resp:
if 300 <= resp.status < 400 and (location := resp.headers.get("location")):
current = urljoin(current, location)
continue
if resp.status != 200:
raise RuntimeError(f"上游返回 {resp.status}")
async for chunk in resp.content.iter_any():
plain = feed(chunk)
if plain:
on_chunk(plain)
return
raise RuntimeError("重定向次数过多")
+47
View File
@@ -0,0 +1,47 @@
"""全局限流 —— per-IP 滑动窗口(内存实现,无额外依赖)。
RATE_LIMIT 环境变量:每 IP 每分钟允许的请求数(默认 120;设 0 关闭)。
在鉴权之前计数(401 爆破秘钥同样受限);超限返回 429 + Retry-After。
"""
from __future__ import annotations
import time
from starlette.requests import Request
from starlette.responses import JSONResponse
WINDOW_SECONDS = 60.0
def make_rate_limiter(limit_per_minute: int):
hits: dict[str, list[float]] = {}
last_cleanup = 0.0
async def middleware(request: Request, call_next):
nonlocal last_cleanup
if request.method == "OPTIONS" or limit_per_minute <= 0:
return await call_next(request)
now = time.monotonic()
# 惰性清理:每窗口顺带删除已过期的 IP 记录,防内存无限增长
if now - last_cleanup > WINDOW_SECONDS:
threshold = now - WINDOW_SECONDS
for ip in [ip for ip, times in hits.items() if not times or times[-1] <= threshold]:
hits.pop(ip, None)
last_cleanup = now
ip = request.client.host if request.client else "unknown"
times = [t for t in hits.get(ip, []) if t > now - WINDOW_SECONDS]
times.append(now)
hits[ip] = times
if len(times) > limit_per_minute:
retry_after = max(1, int(times[0] + WINDOW_SECONDS - now) + 1)
return JSONResponse(
{"code": 429, "msg": f"请求过于频繁(限 {limit_per_minute} 次/分钟),请 {retry_after} 秒后重试"},
status_code=429,
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
return middleware
+38
View File
@@ -0,0 +1,38 @@
"""流代理签名 —— /stream/* 的无头客户端兼容方案。
/url 返回流地址时附加 `?exp=<秒级时间戳>&sig=<HMAC-SHA256(authKey, "path:exp") 前 32 位>`。
与裸 ?key= 的区别:签名值泄露只能在该时效窗口内拉这一条流,不能反推出秘钥、不能调用其它接口。
"""
from __future__ import annotations
import hashlib
import hmac
import time
# 流地址有效期(秒),与 mg 流票据 TTL(10 分钟)对齐;过期后客户端重新 /url 即可
STREAM_SIGN_TTL = 10 * 60
def _sign(auth_key: str, data: str) -> str:
return hmac.new(auth_key.encode(), data.encode(), hashlib.sha256).hexdigest()[:32]
def sign_stream(auth_key: str, path: str) -> str:
"""生成流地址的签名参数(path 为不含 query 的路径,如 /stream/mg/123)。"""
exp = int(time.time() + STREAM_SIGN_TTL)
return f"exp={exp}&sig={_sign(auth_key, f'{path}:{exp}')}"
def verify_stream_sign(auth_key: str, path: str, exp: object, sig: object) -> bool:
"""校验流地址签名(常量时间比较 + 时效窗口)。"""
try:
exp_num = int(float(str(exp)))
except (TypeError, ValueError):
return False
sig_str = str(sig or "")
if not sig_str:
return False
if time.time() > exp_num: # 过期即拒(客户端重新 /url 取新地址)
return False
return hmac.compare_digest(_sign(auth_key, f"{path}:{exp_num}"), sig_str)