Files
MusicServer/sdk/qs/mssdk.ts
T
snowy-yang de2dd311ee feat(qs): 接入官方签名模块、修复扫码登录链路、新增本机登录脚本
- passport:响应自带的 qrcode 图指向已 404 的旧扫码页
  (ucenter_web/app/sdk-next),抖音扫码会提示「无法访问页面」;改为用
  qrcode_index_url 的 token 重建官方 bff-pc.qishui.com/light/invoke/scan_login
  地址,与客户端 renderer 的 getHandleScanCodeH5Img 算法一致。同时状态
  映射按官方 renderer 语义对齐(1/new 待扫码、2/scanned 已扫码待确认、
  3/confirmed 已确认),并采集轮询下发的 Set-Cookie、响应带 redirect_url
  时补跳一次,否则拿不到 sessionid
- qs/mssdk:进程内加载官方 bdms.node 生成 X-Helios / X-Medusa 签名,
  不另起 HTTP 签名服务;缺原生模块时整体降级,不影响匿名可用
- scripts/qs-local-login.mjs:直接读本机官方客户端的 Cookies 与 DeviceV1
  写入 data/auth.json,免扫码(独立脚本,不参与服务运行)

为什么需要 mssdk 签名:汽水对未签名请求回 HTTP 200 + 0 字节,把失败伪装
成空成功,没有签名就分不清「接口异常」与「风控拦截」。
2026-10-03 00:27:19 +08:00

139 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 汽水官方 PC 客户端签名(X-Helios / X-Medusa)—— 可选增强,缺原生模块时整体降级
//
// 原理:直接复用官方客户端自带的原生模块 bdms.node(官方 main 进程就是
// bdms.init({ deviceId }) → generateHttpSignatureHeaders(url, "名字\r\n值\r\n…"),
// 把返回的 name/value 成对写回请求头)。与 sodahub-org/libmssdk 同一套做法,
// 区别是我们进程内加载,不另起 HTTP 签名服务。
// 官方只对 qishui.com 且 **非** /passport/、非 /ttwid/ 的请求签名 —— 登录链路用不到本模块。
// 签名覆盖 URL + 全部请求头;POST 的 x-ss-stub(body 的 MD5 大写)既是签名输入也要随请求发出。
//
// 原生文件(官方客户端二进制,专有、不入库、不再分发)放置位置任选其一:
// 1) 环境变量 QS_MSSDK_DIR 指向同时含 bdms.node 与 metasecml.dll 的目录
// 2) 仓库根 soda-mssdk/(已 gitignore)
// 3) 本机已安装的官方客户端:%LOCALAPPDATA%\Programs\Soda Music\<版本>\resources\app.asar.unpacked
// 从安装包提取:官方 SodaMusic-v*-official-win32_x64.exe → Bandizip 解 NSIS →
// $Var31/app.7z → resources/app.asar.unpacked/{bdms.node, metasecml.dll}
import { createRequire } from 'node:module'
import { existsSync, readdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { getDeviceIds } from './device.ts'
const require = createRequire(import.meta.url)
const MODULE_NAME = 'bdms.node'
const CLIENT_DIR_NAMES = ['Soda Music', 'SodaMusic', '汽水音乐']
// 仓库根(本文件在 sdk/qs/ 下)——不依赖 process.cwd(),从任何目录启动都能找到
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..')
const findModuleDir = (): string | null => {
const candidates: string[] = []
if (process.env.QS_MSSDK_DIR) candidates.push(process.env.QS_MSSDK_DIR)
candidates.push(join(REPO_ROOT, 'soda-mssdk'))
const localAppData = process.env.LOCALAPPDATA
if (localAppData) {
for (const name of CLIENT_DIR_NAMES) {
const root = join(localAppData, 'Programs', name)
if (!existsSync(root)) continue
try {
for (const ver of readdirSync(root)) candidates.push(join(root, ver, 'resources', 'app.asar.unpacked'))
} catch {
// 读不到目录就跳过
}
}
}
return candidates.find(dir => dir && existsSync(join(dir, MODULE_NAME))) ?? null
}
interface BdmsModule {
init: (options: { deviceId: string }) => unknown
generateHttpSignatureHeaders: (url: string, headersLines: string) => string
}
let signer: { module: BdmsModule; deviceId: string; dir: string } | null | undefined
/** 懒加载官方签名模块(失败只提示一次,之后一律按不可用处理) */
const loadSigner = () => {
if (signer !== undefined) return signer
signer = null
try {
const dir = findModuleDir()
if (!dir) return signer
const mod = require(join(dir, MODULE_NAME)) as BdmsModule
if (typeof mod?.init !== 'function' || typeof mod?.generateHttpSignatureHeaders !== 'function') {
console.log(`[qs/mssdk] ${MODULE_NAME} 导出不符合预期,按不可用处理`)
return signer
}
const { deviceId } = getDeviceIds()
mod.init({ deviceId })
signer = { module: mod, deviceId, dir }
console.log(`[qs/mssdk] 官方签名模块已加载: ${dir} (device_id=${deviceId})`)
} catch (err: any) {
console.log(`[qs/mssdk] 官方签名模块不可用,降级: ${err?.message ?? err}`)
}
return signer
}
/** 官方签名是否可用(不影响主流程,仅决定走 PC 通道还是安卓通道) */
export const mssdkAvailable = (): boolean => Boolean(loadSigner())
/** 签名时绑定的设备(与请求 URL 里的 device_id/iid 必须一致) */
export const mssdkDeviceId = (): string => loadSigner()?.deviceId ?? ''
/**
* 给一个 qishui.com 请求签名
* @param headers 必须是「真实要发出去的那一份」(含 cookie / x-ss-stub)
* @returns 需追加的头;不可用时返回 null(调用方走原通道)
*/
export const signQishuiRequest = (url: string, headers: Record<string, string>): Record<string, string> | null => {
const current = loadSigner()
if (!current) return null
try {
const lines = Object.entries(headers)
.filter(([name, value]) => name && value != null)
.map(([name, value]) => `${name}\r\n${value}`)
.join('\r\n')
const fields = String(current.module.generateHttpSignatureHeaders(url, lines)).split('\r\n').filter(Boolean)
const signed: Record<string, string> = {}
for (let i = 0; i + 1 < fields.length; i += 2) signed[fields[i]] = fields[i + 1]
if (!signed['X-Helios'] && !signed['x-helios']) return null
return signed
} catch (err: any) {
console.log(`[qs/mssdk] 签名失败,本次降级: ${err?.message ?? err}`)
return null
}
}
/** 官方 PC 端 UA(签名与 UA 绑定同一客户端版本) */
export const PC_UA = 'LunaPC/3.7.0(452316191)'
/** 官方 PC 端公共查询参数(与签名设备成套;track_v2 / search 等 luna_pc 接口共用) */
export const pcCommonParams = (): Record<string, string> => {
const { deviceId, installId } = getDeviceIds()
return {
aid: '386088',
app_name: 'luna_pc',
region: 'cn',
geo_region: 'cn',
os_region: 'cn',
sim_region: '',
device_id: deviceId,
cdid: '',
iid: installId,
version_name: '3.7.0',
version_code: '30070000',
channel: 'official',
build_mode: 'master',
network_carrier: '',
ac: 'wifi',
tz_name: 'Asia/Shanghai',
resolution: '',
device_platform: 'windows',
device_type: 'Windows',
os_version: 'Windows 11',
fp: deviceId,
}
}
export default { mssdkAvailable, mssdkDeviceId, signQishuiRequest, pcCommonParams, PC_UA }