fix: 修复代码审查发现的 P0/P1 缺陷(含测试加固)
审查明细见 CODE-AUDIT.md。本提交覆盖其中的代码级修复:
P0-2 migu 解密流异常逃逸 → 进程崩溃
src/http/migu-stream.ts 把 transform/onChunk 就地 try/catch,转 reject 并 res.destroy()。
为什么:二者在 res 的 data 事件回调里同步执行,抛出的异常既不在 Promise 链上、
也不在路由层 try/catch 内,会逃逸成 uncaughtException(全项目无全局处理器)直接杀进程。
已用最小复现验证:修复前 exit 9,修复后正常 reject。
P0-1 /login/lr 缺失 → lr 取流 100% 不可用
src/routes-login.ts 补 POST/DELETE /login/lr。
为什么:sdk/lr/auth.ts 早已实现 login、该文件也已 import lrAuth,只差路由注册;
lr 取流强制要求登录态,缺此入口等于整个平台不可用,而 README 已将其文档化。
P0-3 + P1-7 凭据存储
sdk/auth/store.ts 写入改 0600 + 「临时文件 + rename」原子替换;解析失败改为改名备份而非静默清空;
新增 DATA_DIR 环境变量(测试隔离用)。
为什么:原先直接覆写明文 JSON(0644)且解析失败静默置空,写入中途被杀会留下截断 JSON,
下次启动解析失败后,任意一次 setCredentials 都会以空表覆盖,导致全部平台凭据丢失。
P1-9 / P1-10 / P1-11 / P1-12 平台模块缺陷
sdk/kg/krcDecode.ts:空歌词显式 resolve('')(原先裸 return 让 Promise 永久 pending,请求挂死)
sdk/kg/lyric.ts:重试改调 getLyricDownload(原先误调 getLyric,参数语义不同导致重试恒失败)
sdk/kw/songList.ts:重试改自递归(原先误调 getListDetail,返回类型错位且把 tryNum 当 page)
sdk/kw/songList.ts:删除 URL 模板中多余的右花括号
P1-6 /song-list 参数契约
src/routes.ts 移除不生效的 limit 声明。
为什么:各平台 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)),无法统一透传,
保留声明只会让文档与实现不符。
P1-4/5/13/15 冒烟测试加固
test/smoke-test.ts:子实例统一登记 + 进程退出回收(原先异常路径留下孤儿实例,曾累积 5 个);
启动超时 10s→30s 且失败时附子进程输出、提前退出立即报错;显式 SOURCE_ENABLED='' 与 DATA_DIR
隔离(原先继承 .env,取流用例走外部音源掩盖 SDK 回归,且 /login/refresh 会改写真实 data/auth.json);
AUTH 断言移入 test()(原先裸抛错会跳过实例回收);修正 642 行过期断言并补 kw parse 正向用例。
P1-8 src/auth/store.ts 删除引用已不存在命令 `pnpm build` 的过期注释。
src/server.ts 头注释的接口清单改为 path 风格,与实际路由一致。
注:src/routes.ts、src/routes-login.ts、src/server.ts、test/smoke-test.ts 同时承载了工作区
既有的在途改动(parse/playlist/album 路由接入与相应用例),一并随本提交入库。
This commit is contained in:
1 parent
ece9c5fc9a
commit
47ce651f46
10 files changed
+825
-73
No files matched your search
+35
-7
@@ -1,12 +1,17 @@
|
||||
// 登录态存储 —— 各平台凭据持久化(data/auth.json,不入库)
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...} }
|
||||
// 注意:store.js 为 tsc 就地发射产物(不入库);改逻辑只改本文件,然后 pnpm build
|
||||
// 结构:{ wy: {...}, kg: {...}, tx: {...}, _kg_device: {...}, _qs_device: {...}, _qs_passport_device: {...} }
|
||||
// 凭据是明文敏感数据:文件以 0600 创建;写入走「同目录临时文件 + rename」原子替换,
|
||||
// 避免写中途崩溃留下截断 JSON(那会导致下次启动解析失败而丢掉全部平台凭据)。
|
||||
// DATA_DIR 环境变量可覆盖存储目录(测试用,避免污染真实登录态)。
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const DATA_FILE = join(dirname(fileURLToPath(import.meta.url)), '../../data/auth.json')
|
||||
const DATA_DIR = process.env.DATA_DIR
|
||||
? process.env.DATA_DIR
|
||||
: join(dirname(fileURLToPath(import.meta.url)), '../../data')
|
||||
const DATA_FILE = join(DATA_DIR, 'auth.json')
|
||||
|
||||
export interface WyCredentials {
|
||||
cookie: string
|
||||
@@ -72,9 +77,23 @@ let loaded = false
|
||||
|
||||
const load = (): Record<string, any> => {
|
||||
if (loaded) return cache
|
||||
if (!existsSync(DATA_FILE)) {
|
||||
cache = {}
|
||||
loaded = true
|
||||
return cache
|
||||
}
|
||||
try {
|
||||
cache = existsSync(DATA_FILE) ? JSON.parse(readFileSync(DATA_FILE, 'utf8')) : {}
|
||||
} catch {
|
||||
cache = JSON.parse(readFileSync(DATA_FILE, 'utf8'))
|
||||
} catch (err) {
|
||||
// 解析失败绝不能静默清空:先改名备份,让残留凭据仍可人工抢救;
|
||||
// 否则下一次 setCredentials 就会用空表覆盖掉本可恢复的文件。
|
||||
const backup = `${DATA_FILE}.corrupt-${Date.now()}`
|
||||
try {
|
||||
renameSync(DATA_FILE, backup)
|
||||
console.error(`[auth/store] ${DATA_FILE} 解析失败,已备份为 ${backup},本次以空凭据启动: ${(err as Error).message}`)
|
||||
} catch {
|
||||
console.error(`[auth/store] ${DATA_FILE} 解析失败且备份未成功,本次以空凭据启动: ${(err as Error).message}`)
|
||||
}
|
||||
cache = {}
|
||||
}
|
||||
loaded = true
|
||||
@@ -85,7 +104,16 @@ const save = (data: Record<string, any>): void => {
|
||||
cache = data
|
||||
loaded = true
|
||||
mkdirSync(dirname(DATA_FILE), { recursive: true })
|
||||
writeFileSync(DATA_FILE, JSON.stringify(data, null, 2))
|
||||
// 原子写:先落同目录临时文件(0600,仅属主可读),再 rename 覆盖。
|
||||
// 直接覆写会在进程中途被杀时留下截断 JSON —— 那正是「凭据全丢」的触发路径。
|
||||
const tmp = `${DATA_FILE}.${process.pid}.tmp`
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify(data, null, 2), { mode: 0o600 })
|
||||
renameSync(tmp, DATA_FILE)
|
||||
} catch (err) {
|
||||
try { unlinkSync(tmp) } catch { /* 清理失败不掩盖原始错误 */ }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export const getCredentials = (platform: string): any => load()[platform] ?? null
|
||||
|
||||
+3
-1
@@ -7,7 +7,9 @@ import { decodeName } from '../common/utils.ts'
|
||||
const enc_key = Buffer.from([0x40, 0x47, 0x61, 0x77, 0x5e, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2d, 0xce, 0xd2, 0x6e, 0x69])
|
||||
|
||||
const decodeLyric = str => new Promise((resolve, reject) => {
|
||||
if (!str.length) return
|
||||
// 空歌词必须显式 resolve:executor 里裸 return 会让 Promise 永久 pending,
|
||||
// 调用方的 await 将一直挂起(连接与内存无法释放,只能等客户端超时)
|
||||
if (!str.length) return resolve('')
|
||||
const buf_str = Buffer.from(str, 'base64').subarray(4)
|
||||
for (let i = 0, len = buf_str.length; i < len; i++) {
|
||||
buf_str[i] = buf_str[i] ^ enc_key[i % 16]
|
||||
|
||||
+2
-1
@@ -69,7 +69,8 @@ export default {
|
||||
requestObj.promise = (requestObj.promise as any).then(({ body, statusCode }) => {
|
||||
if (statusCode !== 200) {
|
||||
if (tryNum > 5) return Promise.reject(new Error('歌词获取失败'))
|
||||
let tryRequestObj = (this.getLyric as any)(id, accessKey, fmt, ++tryNum)
|
||||
// 重试必须回到本方法 getLyricDownload;原先误调 getLyric(参数语义不同)导致重试恒失败
|
||||
const tryRequestObj = this.getLyricDownload(id, accessKey, fmt, ++tryNum)
|
||||
requestObj.cancelHttp = tryRequestObj.cancelHttp.bind(tryRequestObj)
|
||||
return tryRequestObj.promise
|
||||
}
|
||||
|
||||
+4
-2
@@ -193,14 +193,16 @@ export default {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node=${id}&pn=0&rn=1&fmt=json&src=mbox&level=2`)
|
||||
return requestObj.promise.then(({ statusCode, body }) => {
|
||||
if (statusCode != 200 || !body.child) return this.getListDetail(id, ++tryNum)
|
||||
// 本方法须返回 detailId(调用方据此再查曲目),失败时应重试自身;
|
||||
// 原先误调 getListDetail(返回歌单详情对象、且把 tryNum 当 page)导致返回类型错位
|
||||
if (statusCode != 200 || !body.child) return this.getListDetailDigest5Info(id, ++tryNum)
|
||||
// console.log(body)
|
||||
return body.child.length ? body.child[0].sourceid : null
|
||||
})
|
||||
},
|
||||
getListDetailDigest5Music(id, page, tryNum = 0) {
|
||||
if (tryNum > 2) return Promise.reject(new Error('try max num'))
|
||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||
const requestObj = httpFetch(`http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${id}&pn=${page - 1}&rn=${this.limit_song}&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1`)
|
||||
return requestObj.promise.then(({ body }) => {
|
||||
// console.log(body)
|
||||
if (body.result !== 'ok') return this.getListDetail(id, page, ++tryNum)
|
||||
|
||||
+2
-1
@@ -50,5 +50,6 @@ const lr = {
|
||||
},
|
||||
}
|
||||
|
||||
export { status } from './auth.ts'
|
||||
// login 一并导出:/login/lr 路由直接引用 sdk/lr/auth.ts,SDK 使用者也需要能拿到登录能力
|
||||
export { login, status } from './auth.ts'
|
||||
export default lr
|
||||
@@ -0,0 +1,120 @@
|
||||
// 咪咕加密流票据 —— /url/migu 命中 Z3D/I3D 加密候选时登记,/stream/migu/:id 凭票解密
|
||||
// (统一入口为 /stream/:source/:id,source=migu 时 id 即此处返回的票据)。
|
||||
// 不把上游 URL 塞进播放地址(Fastify path 参数限长,且 URL 含时效签名与中文路径),
|
||||
// 票据短时有效(10 分钟,够播放器起播),进程内存储、不落盘。
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { get as httpGet } from 'node:http'
|
||||
import { get as httpsGet } from 'node:https'
|
||||
|
||||
interface MiguStreamTicket {
|
||||
url: string
|
||||
fileKey: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
const TTL_MS = 10 * 60 * 1000
|
||||
const STREAM_CACHE_LIMIT = 200
|
||||
const tickets = new Map<string, MiguStreamTicket>()
|
||||
|
||||
const prune = () => {
|
||||
const now = Date.now()
|
||||
for (const [id, ticket] of tickets) {
|
||||
if (now - ticket.createdAt > TTL_MS) tickets.delete(id)
|
||||
}
|
||||
// 超出容量时按插入顺序淘汰最旧票据
|
||||
while (tickets.size > STREAM_CACHE_LIMIT) {
|
||||
const oldest = tickets.keys().next().value
|
||||
if (oldest == null) break
|
||||
tickets.delete(oldest)
|
||||
}
|
||||
}
|
||||
|
||||
/** 登记一条加密流,返回可直接放入 /stream/migu/:id 的短票据 id */
|
||||
export const registerMiguStream = (url: string, fileKey: string): string => {
|
||||
prune()
|
||||
const id = randomUUID().replace(/-/g, '')
|
||||
tickets.set(id, { url, fileKey, createdAt: Date.now() })
|
||||
return id
|
||||
}
|
||||
|
||||
/** 取出票据(过期返回 null) */
|
||||
export const takeMiguStream = (id: string): MiguStreamTicket | null => {
|
||||
const ticket = tickets.get(id)
|
||||
if (!ticket) return null
|
||||
if (Date.now() - ticket.createdAt > TTL_MS) {
|
||||
tickets.delete(id)
|
||||
return null
|
||||
}
|
||||
return ticket
|
||||
}
|
||||
|
||||
const MIGU_STREAM_UA = 'Android_migu/7.41.13 okhttp/3.12.13'
|
||||
|
||||
/** 按扩展名推断明文的 content-type */
|
||||
export const miguStreamContentType = (url: string): string => {
|
||||
if (url.includes('.flac')) return 'audio/flac'
|
||||
if (url.includes('.wav')) return 'audio/wav'
|
||||
if (url.includes('.m4a')) return 'audio/mp4'
|
||||
return 'audio/mpeg'
|
||||
}
|
||||
|
||||
/**
|
||||
* 流式下载密流并逐块解密后写入响应。
|
||||
* 咪咕 Z3D 的解密是逐字节 `plain[i] = cipher[i] - streamKey[i % 32]`,
|
||||
* 因此可按块处理(保持全局偏移),无需把整段音频(可达上百 MB,超过 httpFetch 的缓冲上限)读进内存。
|
||||
* @param decryptChunk 处理单块(参数:密文块、块在流中的起始偏移),返回明文块
|
||||
*/
|
||||
export const pipeDecryptedMiguStream = (
|
||||
url: string,
|
||||
transform: (chunk: Buffer, offset: number) => Buffer,
|
||||
onChunk: (plain: Buffer, offset: number) => void,
|
||||
redirectsLeft = 3,
|
||||
): Promise<void> => new Promise((resolve, reject) => {
|
||||
const get = url.startsWith('https:') ? httpsGet : httpGet
|
||||
// 只 settle 一次:异常后不再 resolve/reject,避免二次结算被忽略或覆盖真实错误
|
||||
let settled = false
|
||||
const settle = (fn: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
fn()
|
||||
}
|
||||
const req = get(url, {
|
||||
headers: { 'User-Agent': MIGU_STREAM_UA, Referer: 'https://music.migu.cn/' },
|
||||
}, (res: any) => {
|
||||
const status = res.statusCode ?? 0
|
||||
if (status >= 300 && status < 400 && res.headers.location) {
|
||||
res.resume()
|
||||
if (redirectsLeft <= 0) return settle(() => reject(new Error('重定向次数过多')))
|
||||
const next = new URL(res.headers.location, url).toString()
|
||||
return pipeDecryptedMiguStream(next, transform, onChunk, redirectsLeft - 1).then(
|
||||
() => settle(resolve),
|
||||
err => settle(() => reject(err)),
|
||||
)
|
||||
}
|
||||
if (status !== 200) {
|
||||
res.resume()
|
||||
return settle(() => reject(new Error(`上游返回 ${status}`)))
|
||||
}
|
||||
let offset = 0
|
||||
// transform/onChunk 由调用方提供(路由层会在解密结果非音频时 throw)。
|
||||
// 它们在 data 事件回调里同步执行:若直接抛出会逃逸成 uncaughtException 并终止整个进程,
|
||||
// 因此必须就地捕获、转成 reject,并 destroy 上游停止后续读取。
|
||||
const fail = (err: unknown) => settle(() => {
|
||||
res.destroy()
|
||||
reject(err)
|
||||
})
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
if (settled) return
|
||||
try {
|
||||
const plain = transform(chunk, offset)
|
||||
offset += chunk.length
|
||||
onChunk(plain, offset - plain.length)
|
||||
} catch (err) {
|
||||
fail(err)
|
||||
}
|
||||
})
|
||||
res.on('end', () => settle(resolve))
|
||||
res.on('error', fail)
|
||||
})
|
||||
req.on('error', (err: unknown) => settle(() => reject(err)))
|
||||
})
|
||||
+91
-5
@@ -5,8 +5,13 @@ import * as wyAuth from '../sdk/auth/wy.ts'
|
||||
import * as kgAuth from '../sdk/auth/kg.ts'
|
||||
import * as txAuth from '../sdk/auth/tx.ts'
|
||||
import * as qsPassport from '../sdk/qs/passport.ts'
|
||||
import * as biliAuth from '../sdk/bili/login.ts'
|
||||
import { status as xmStatus } from '../sdk/xm/index.ts'
|
||||
import { status as meStatus } from '../sdk/me/index.ts'
|
||||
import { status as qianStatus } from '../sdk/qian/index.ts'
|
||||
import { status as jooxStatus } from '../sdk/joox/index.ts'
|
||||
import { status as appleStatus } from '../sdk/apple/index.ts'
|
||||
import { status as fivesingStatus } from '../sdk/5sing/index.ts'
|
||||
import * as lrAuth from '../sdk/lr/auth.ts'
|
||||
import { clearCredentials, getCredentials, getTxAuth, setCredentials } from '../sdk/auth/store.ts'
|
||||
import { refreshAll } from './refresh.ts'
|
||||
@@ -288,8 +293,11 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.post('/login/qs/qr', {
|
||||
schema: {
|
||||
summary: '获取汽水音乐扫码登录二维码',
|
||||
description: '用「抖音 APP」扫码(汽水 App 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
|
||||
'返回 qrimg(data URL)可直接展示,token 需原样回传。登录后 qs 取流走签名 track_v2,解锁完整曲目。',
|
||||
description: '用「汽水音乐 APP」扫一扫(官方 PC 客户端同款文案:打开汽水音乐 APP → 搜索框右侧扫一扫;' +
|
||||
'抖音 APP 扫可能不完成确认)后轮询 /login/qs/qr/check 完成登录。' +
|
||||
'返回 qrimg(data URL)可直接展示,token 需原样回传;二维码内容为官方 PC 客户端同款扫码页' +
|
||||
'(bff-pc.qishui.com/light/invoke/scan_login,另见 scanUrl 字段)。轮询间隔建议 ≥2 秒。' +
|
||||
'登录后 qs 取流走签名 track_v2,解锁完整曲目。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async(_request, reply) => {
|
||||
@@ -303,7 +311,7 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.post('/login/qs/qr/check', {
|
||||
schema: {
|
||||
summary: '轮询汽水音乐扫码状态',
|
||||
description: 'code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(自动保存凭据)。',
|
||||
description: 'code:800 二维码过期/被拒 / 801 等待扫码 / 802 已扫码待确认(含需二次验证)/ 803 登录成功(自动保存凭据)。',
|
||||
tags: ['登录'],
|
||||
body: { type: 'object', properties: { token: { type: 'string' } }, required: ['token'] },
|
||||
},
|
||||
@@ -382,6 +390,78 @@ export const loginRoutes = async(fastify) => {
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 懒人听书 ----------
|
||||
|
||||
fastify.post('/login/lr', {
|
||||
schema: {
|
||||
summary: '懒人听书账号登录',
|
||||
description: 'm.lrts.me 账号密码登录(密码按上游要求以 base64 提交)。成功后会话 Cookie 持久化到 data/auth.json,' +
|
||||
'此后 lr 取流自动携带 —— 懒人听书要求登录态,免费章节同样如此。',
|
||||
tags: ['登录'],
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
account: { type: 'string', description: '账号(手机号)' },
|
||||
password: { type: 'string', description: '密码(明文传入,服务端自行编码)' },
|
||||
},
|
||||
required: ['account', 'password'],
|
||||
},
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
const { account, password } = request.body
|
||||
try {
|
||||
return await lrAuth.login({ account, password })
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/lr', {
|
||||
schema: { summary: '退出懒人听书登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('lr')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 哔哩哔哩 ----------
|
||||
|
||||
fastify.post('/login/bili/qr', {
|
||||
schema: {
|
||||
summary: '获取哔哩哔哩扫码登录二维码',
|
||||
description: '用 B站 App 扫码后轮询 /login/bili/qr/check 完成登录。返回 key 需原样回传,qrimg 为 data URL 可直接展示。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await biliAuth.createQRLogin()
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.post('/login/bili/qr/check', {
|
||||
schema: {
|
||||
summary: '轮询哔哩哔哩扫码状态',
|
||||
description: 'status:waiting 等待扫码 / scanned 已扫码待确认 / success 登录成功(自动保存凭据并返回账号资料)' +
|
||||
'/ expired 二维码过期 / failed 失败。code 为 B站原始返回码(86101/86038/86090/0)。',
|
||||
tags: ['登录'],
|
||||
body: { type: 'object', properties: { key: { type: 'string' } }, required: ['key'] },
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
try {
|
||||
return await biliAuth.checkQRLogin(request.body.key)
|
||||
} catch (err) {
|
||||
return fail(reply, err)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.delete('/login/bili', {
|
||||
schema: { summary: '退出哔哩哔哩登录', tags: ['登录'] },
|
||||
}, async() => {
|
||||
clearCredentials('bili')
|
||||
return { logged: false }
|
||||
})
|
||||
|
||||
// ---------- 通用 ----------
|
||||
|
||||
fastify.post('/login/refresh', {
|
||||
@@ -396,13 +476,14 @@ export const loginRoutes = async(fastify) => {
|
||||
fastify.get('/login/status', {
|
||||
schema: {
|
||||
summary: '查看登录状态',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx 为已存凭据(含 VIP 等级与最近更新时间),qs/xm/me/lr 为已存凭据。',
|
||||
description: 'wy 为实时校验(未登录或 cookie 失效返回 logged: false),kg/tx/bili 为已存凭据(含 VIP 等级与最近更新时间),' +
|
||||
'qs/xm/me/lr/qian/joox/apple/5sing 为已存凭据。',
|
||||
tags: ['登录'],
|
||||
},
|
||||
}, async() => {
|
||||
const [wy, kg] = await Promise.all([wyAuth.status().catch(() => null), Promise.resolve(kgAuth.status())])
|
||||
const tx = getTxAuth()
|
||||
const txCred = getCredentials('tx') as { vipType?: string; updatedAt?: string } | null
|
||||
const txCred = getCredentials('tx') as { vipType?: string, updatedAt?: string } | null
|
||||
const kgCred = getCredentials('kg') as { updatedAt?: string } | null
|
||||
return {
|
||||
wy: wy ? { logged: true, ...wy } : { logged: false },
|
||||
@@ -412,6 +493,11 @@ export const loginRoutes = async(fastify) => {
|
||||
xm: xmStatus(),
|
||||
me: meStatus(),
|
||||
lr: lrAuth.status(),
|
||||
bili: biliAuth.status(),
|
||||
qian: qianStatus(),
|
||||
joox: jooxStatus(),
|
||||
apple: appleStatus(),
|
||||
'5sing': fivesingStatus(),
|
||||
}
|
||||
})
|
||||
}
|
||||
+302
-34
@@ -5,6 +5,7 @@
|
||||
|
||||
import { notFound, upstream } from './http/api-error.ts'
|
||||
import { signStream } from './http/stream-sign.ts'
|
||||
import { registerMiguStream, takeMiguStream } from './http/migu-stream.ts'
|
||||
import { getMusicInfo } from '../sdk/kg/musicInfo.ts'
|
||||
|
||||
const QUALITIES = ['64k', '128k', '320k', 'flac', 'flac24bit']
|
||||
@@ -48,12 +49,29 @@ const pId = { type: 'string', description: '平台歌曲 id', example: '28423286
|
||||
const qs = (properties: any, required: string[] = []) => ({ type: 'object', properties, required })
|
||||
const params = (properties: any, required?: string[]) => qs(properties, required ?? Object.keys(properties))
|
||||
|
||||
/** 能力位取值:支持 `album.searchAlbum` 形式的嵌套路径 */
|
||||
const capabilityOf = (m: any, capability?: string) => {
|
||||
if (!m || !capability) return m
|
||||
let cur = m
|
||||
for (const key of capability.split('.')) {
|
||||
if (cur == null) return undefined
|
||||
cur = cur[key]
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
const getSource = (musicSdk: any, name: string, capability?: string) => {
|
||||
const m = musicSdk[name]
|
||||
if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`)
|
||||
if (!m || !capabilityOf(m, capability)) throw notFound(`不支持的平台: ${name}`)
|
||||
return m
|
||||
}
|
||||
|
||||
/** 歌单分类返回形状不一({list}/裸数组),统一为 { source, list } */
|
||||
const normalizeCategories = (source: string, res: any) => {
|
||||
const list = Array.isArray(res) ? res : (res?.list ?? [])
|
||||
return { source, list }
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册全部业务路由
|
||||
* @param {import('fastify').FastifyInstance} fastify
|
||||
@@ -160,9 +178,14 @@ export const musicRoutes = async(fastify: any) => {
|
||||
// 取流链:外部音源脚本(SOURCE_ENABLED 启用时)→ SDK 官方直连 → 酷我兜底(跨平台匹配)
|
||||
const finish = (res: any, via: string) => {
|
||||
if (!res?.url) throw new Error('获取播放地址失败')
|
||||
// 加密流(migu 的 Z3D/I3D):客户端无法自行解密,改指向服务端解密代理 /stream/migu/<票据>
|
||||
let rawUrl = String(res.url)
|
||||
if (res.encrypted && res.fileKey) {
|
||||
rawUrl = `/stream/migu/${registerMiguStream(rawUrl, String(res.fileKey))}`
|
||||
}
|
||||
// qs 解密流等代理地址返回相对路径,按请求的协议与 host 补全(兼容本机/局域网播放器);
|
||||
// 启用 AUTH_KEY 时附带短时签名(播放器拉流无法携带自定义头,签名不暴露秘钥本身)
|
||||
let url = String(res.url)
|
||||
let url = rawUrl
|
||||
if (url.startsWith('/')) {
|
||||
const suffix = process.env.AUTH_KEY
|
||||
? `${url.includes('?') ? '&' : '?'}${signStream(process.env.AUTH_KEY, url.split('?')[0])}`
|
||||
@@ -237,27 +260,69 @@ export const musicRoutes = async(fastify: any) => {
|
||||
},
|
||||
}, urlHandler)
|
||||
|
||||
// qs 解密流代理:登录态取流返回 /stream/qs/:id 相对地址,本端点完成"签名 track_v2 →
|
||||
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"。raw 豁免 { code, data } 信封包装
|
||||
fastify.get('/stream/qs/:id', {
|
||||
// 加密流解密代理(qs 汽水解密流 / migu 咪咕 Z3D 加密流):统一 /stream/:source/:id,用 path 段 source 区分平台。
|
||||
// - qs:/url/qs 登录态取流返回相对地址 /stream/qs/<trackId>,本端点完成"签名 track_v2 →
|
||||
// 密音频下载 → spade key + AES-CTR 解密 → 回明文流"
|
||||
// - migu:/url/migu 命中 Z3D/I3D 加密候选时登记票据并返回 /stream/migu/<票据>,本端点凭票下载密流逐字节解密
|
||||
// 两者均由 /url 产出相对地址,路由层负责补全 origin;raw 豁免 { code, data } 信封包装
|
||||
fastify.get('/stream/:source/:id', {
|
||||
config: { raw: true },
|
||||
schema: {
|
||||
summary: '汽水音乐解密流代理',
|
||||
description: '登录态取流地址(/url/qs 返回的相对地址)。服务端完成 track_v2 签名请求与 cenc-aes-ctr 本地解密,返回明文音频。',
|
||||
summary: '加密流解密代理(qs / migu)',
|
||||
description: '取流响应中返回的相对地址(/url/qs 的流地址、/url/migu 命中加密流时的地址),' +
|
||||
'服务端下载上游密流并在本地解密后返回明文音频;不接受客户端自行构造。' +
|
||||
'source=qs 时 id 为汽水 track id(track_v2 签名 + cenc-aes-ctr 解密);' +
|
||||
'source=migu 时 id 为取流响应 url 中的票据段(票据 10 分钟内有效,过期请重新 /url/migu 取流)。',
|
||||
tags: ['歌曲资源'],
|
||||
params: params({ id: pId }),
|
||||
params: params({
|
||||
source: { type: 'string', enum: ['qs', 'migu'], description: '平台 id:qs=汽水音乐解密流 / migu=咪咕加密流', example: 'qs' },
|
||||
id: { type: 'string', description: 'qs: 汽水 track id;migu: 取流响应 url 中的票据段(无需自行构造)', example: '7123456789' },
|
||||
}),
|
||||
},
|
||||
}, async(request, reply) => {
|
||||
const { id } = request.params
|
||||
try {
|
||||
const entry = await fastify.musicSdk.qs.getStreamBuffer(id)
|
||||
reply.type(entry.contentType || 'audio/mp4')
|
||||
reply.header('Accept-Ranges', 'none')
|
||||
reply.header('Content-Length', entry.buffer.length)
|
||||
return reply.send(entry.buffer)
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
const { source, id } = request.params
|
||||
|
||||
// 汽水:track id → 签名请求 → 下载密音频 → 本地解密为明文
|
||||
if (source === 'qs') {
|
||||
try {
|
||||
const entry = await fastify.musicSdk.qs.getStreamBuffer(String(id))
|
||||
reply.type(entry.contentType || 'audio/mp4')
|
||||
reply.header('Accept-Ranges', 'none')
|
||||
reply.header('Content-Length', entry.buffer.length)
|
||||
return reply.send(entry.buffer)
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 咪咕:票据 → 逐块解密流
|
||||
const ticket = takeMiguStream(String(id))
|
||||
if (!ticket) throw notFound('解密票据不存在或已过期(请重新调用 /url/migu 取流)')
|
||||
// 流式下载 + 逐块解密:咪咕 Z3D 密钥流按 32 字节循环,块内保持全局偏移即可,
|
||||
// 避免把上百 MB 的 wav/flac 整段读进内存(httpFetch 有 64MB 缓冲上限)
|
||||
const { miguZ3DStreamKey, isMiguAudioMagic } = await import('../sdk/migu/util.ts')
|
||||
const { pipeDecryptedMiguStream, miguStreamContentType } = await import('./http/migu-stream.ts')
|
||||
const streamKey = miguZ3DStreamKey(ticket.fileKey)
|
||||
reply.raw.writeHead(200, {
|
||||
'Content-Type': miguStreamContentType(ticket.url),
|
||||
'Accept-Ranges': 'none',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
})
|
||||
try {
|
||||
await pipeDecryptedMiguStream(ticket.url, (chunk, offset) => {
|
||||
const plain = Buffer.alloc(chunk.length)
|
||||
for (let i = 0; i < chunk.length; i++) plain[i] = chunk[i]! - streamKey[(offset + i) & 31]!
|
||||
return plain
|
||||
}, (plain, offset) => {
|
||||
if (offset === 0 && !isMiguAudioMagic(plain)) throw new Error('解密结果不是有效音频')
|
||||
reply.raw.write(plain)
|
||||
})
|
||||
reply.raw.end()
|
||||
} catch (err: any) {
|
||||
// 响应头已发出(流式),只能中断连接;未发出时走统一错误处理
|
||||
reply.raw.destroy(new Error(err?.message || 'migu 解密流失败'))
|
||||
}
|
||||
return reply
|
||||
})
|
||||
|
||||
const lyricHandler = async(request) => {
|
||||
@@ -382,7 +447,8 @@ export const musicRoutes = async(fastify: any) => {
|
||||
page: pPage,
|
||||
}, withPage ? undefined : ['source']),
|
||||
querystring: qs({
|
||||
limit: pLimit,
|
||||
// 注:本接口无 limit —— 各平台的 getList 签名不一致(如 kw 为 (sortId, tagId, page, tryNum)),
|
||||
// 条数由平台接口自身决定,无法统一透传;此前 schema 声明 limit 但实现忽略,属文档说谎
|
||||
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' },
|
||||
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' },
|
||||
}),
|
||||
@@ -447,7 +513,8 @@ export const musicRoutes = async(fastify: any) => {
|
||||
|
||||
const albumSchema = (withPage: boolean) => ({
|
||||
summary: '获取专辑详情与曲目列表',
|
||||
description: '支持 kg/kw/tx/xm/me(见 /sources 的 album 能力位)。kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
|
||||
description: '支持 kg/kw/tx/wy/xm/me/qt/lr/migu/qian/joox/apple(见 /sources 的 album 能力位)。' +
|
||||
'kw 单页固定 1000 条、xm/me 固定 30 条、limit 不生效。',
|
||||
tags: ['歌手专辑'],
|
||||
params: params({
|
||||
source: pSource('album'),
|
||||
@@ -468,6 +535,205 @@ export const musicRoutes = async(fastify: any) => {
|
||||
return album.getAlbumListDetail(id, page).catch(err => { throw upstream(err.message) })
|
||||
})
|
||||
|
||||
fastify.get('/album/:source/search/:keyword', {
|
||||
schema: {
|
||||
summary: '搜索专辑',
|
||||
description: '支持 kg/kw/tx/wy/migu/qian/joox/apple(见 /sources 的 albumSearch 能力位)。',
|
||||
tags: ['歌手专辑'],
|
||||
params: params({ source: pSource('album.searchAlbum'), keyword: { type: 'string', description: '专辑名关键词', example: '魔杰座' } }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('AlbumDetailResult', { notFoundDoc: false }),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source, keyword } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'album.searchAlbum').album
|
||||
try {
|
||||
const res = await m.searchAlbum(keyword, page, limit)
|
||||
// 各平台返回形状不一(有的 {list,total},有的裸数组),统一成列表形状
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 歌单(搜索 / 详情 / 分类) ----------
|
||||
|
||||
fastify.get('/playlist/:source/search/:keyword', {
|
||||
schema: {
|
||||
summary: '搜索歌单',
|
||||
description: '支持 kg/kw/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistSearch 能力位)。' +
|
||||
'部分平台该能力受限(如 qian 歌单搜索接口已下线、bili 需登录),失败时返回 502。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('playlist.searchPlaylist'), keyword: { type: 'string', example: '华语' } }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('PlaylistSearchResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source, keyword } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'playlist.searchPlaylist').playlist
|
||||
try {
|
||||
const res = await m.searchPlaylist(keyword, page, limit)
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
fastify.get('/playlist/:source/categories', {
|
||||
schema: {
|
||||
summary: '获取歌单分类',
|
||||
description: '支持 kw/kg/tx/wy/migu/qian/joox/bili/5sing/apple(见 /sources 的 playlistCategories 能力位)。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('playlist.getCategories') }),
|
||||
response: responses('PlaylistCategoriesResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const m = getSource(musicSdk, request.params.source, 'playlist.getCategories').playlist
|
||||
try {
|
||||
return normalizeCategories(request.params.source, await m.getCategories())
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
const categoriesResult = async(source: string, categoryId: string, page: number, limit: number) => {
|
||||
const m = getSource(musicSdk, source, 'playlist').playlist
|
||||
if (typeof m.getCategoryPlaylists !== 'function') throw notFound(`平台 ${source} 不支持分类歌单`)
|
||||
try {
|
||||
const res = await m.getCategoryPlaylists(categoryId, page, limit)
|
||||
if (Array.isArray(res)) return { source, list: res, page, limit, total: res.length }
|
||||
return { source, ...res }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
fastify.get('/playlist/:source/categories/:categoryId', {
|
||||
schema: {
|
||||
summary: '获取分类下的歌单',
|
||||
description: 'categoryId 为 /playlist/:source/categories 返回的分类 id(部分平台 id 即分类名)。',
|
||||
tags: ['歌单'],
|
||||
params: params({
|
||||
source: pSource('playlist.getCategories'),
|
||||
categoryId: { type: 'string', description: '分类 id(分类列表返回)', example: '华语' },
|
||||
}),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('PlaylistSearchResult'),
|
||||
},
|
||||
}, async(request) => categoriesResult(
|
||||
request.params.source, request.params.categoryId, request.query.page, request.query.limit,
|
||||
))
|
||||
|
||||
const playlistSchema = (withPage: boolean) => ({
|
||||
summary: '获取歌单详情与曲目列表',
|
||||
description: 'id 为歌单 id 或链接(部分平台支持链接/裸 id,如 bili 的 season:<id>:<mid>:<bvid>、' +
|
||||
'joox/qian/migu 的网页链接)。列表按 page/limit 切片(聚合接口自身分页的平台由平台决定)。',
|
||||
tags: ['歌单'],
|
||||
params: params({
|
||||
source: pSource('playlist'),
|
||||
id: { type: 'string', description: '歌单 id(或平台歌单链接)', example: '123456789' },
|
||||
page: pPage,
|
||||
}, withPage ? undefined : ['source', 'id']),
|
||||
querystring: qs({ limit: pLimit }),
|
||||
response: responses('PlaylistDetailResult'),
|
||||
})
|
||||
pagedGet('/playlist/:source/:id', playlistSchema, async(request) => {
|
||||
const { source, id } = request.params
|
||||
const { limit } = request.query
|
||||
const page = Number(request.params.page) || 1
|
||||
const m = getSource(musicSdk, source, 'playlist').playlist
|
||||
try {
|
||||
// 上游/移植的详情接口普遍自带分页(page, limit)
|
||||
return await m.getPlaylistDetail(id, page, limit)
|
||||
} catch (err: any) {
|
||||
// 部分平台详情签名只有 id:退化为取全量后本地切片
|
||||
try {
|
||||
const songs = await m.getPlaylistSongs(id)
|
||||
if (!Array.isArray(songs)) throw err
|
||||
const start = (page - 1) * limit
|
||||
return {
|
||||
source,
|
||||
info: { name: id, img: '', desc: '', author: '' },
|
||||
list: songs.slice(start, start + limit),
|
||||
page,
|
||||
limit,
|
||||
total: songs.length,
|
||||
}
|
||||
} catch {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 链接解析(歌曲/专辑/歌单链接 → 结构化数据) ----------
|
||||
|
||||
const parseBodySchema = { type: 'object', properties: { link: { type: 'string', description: '平台链接或裸 id' } }, required: ['link'] }
|
||||
|
||||
const parseHandler = (kind: 'song' | 'album' | 'playlist') => async(request: any) => {
|
||||
const { source } = request.params
|
||||
const link = String(request.body?.link ?? '').trim()
|
||||
const m = getSource(musicSdk, source, 'parse')
|
||||
const impl = m.parse?.[`parse${kind[0]!.toUpperCase()}${kind.slice(1)}`]
|
||||
if (typeof impl !== 'function') throw notFound(`平台 ${source} 不支持解析该类型链接`)
|
||||
try {
|
||||
const out = await impl(link)
|
||||
// 统一响应:{ source, type, song | playlist+list }
|
||||
if (kind === 'song') {
|
||||
if (out?.list || out?.songs) {
|
||||
const list = out.list ?? out.songs
|
||||
return { source, type: 'playlist', playlist: out.info ?? out.playlist ?? null, list, total: list.length }
|
||||
}
|
||||
return { source, type: 'song', song: out }
|
||||
}
|
||||
const list = out?.list ?? out?.songs ?? []
|
||||
return { source, type: kind, playlist: out?.info ?? out?.playlist ?? out, list, total: list.length }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
for (const kind of ['song', 'album', 'playlist'] as const) {
|
||||
fastify.post(`/parse/${kind}/:source`, {
|
||||
schema: {
|
||||
summary: `解析${kind === 'song' ? '歌曲' : kind === 'album' ? '专辑' : '歌单'}链接`,
|
||||
description: '把平台链接(或裸 id)解析成结构化歌曲/曲目数据。支持平台见 /sources 的 parse 能力位;' +
|
||||
'5sing 歌曲/歌单、apple/bili/joox/qian/migu 三类、kw/kg/tx/wy 部分支持。',
|
||||
tags: ['链接解析'],
|
||||
params: params({ source: pSource('parse.parseSong') }),
|
||||
body: parseBodySchema,
|
||||
response: responses('ParseResult'),
|
||||
},
|
||||
}, parseHandler(kind))
|
||||
}
|
||||
|
||||
// ---------- 个人歌单(需登录态) ----------
|
||||
|
||||
fastify.get('/user-playlist/:source', {
|
||||
schema: {
|
||||
summary: '获取个人歌单列表',
|
||||
description: '需先登录(见 /login/*)。当前仅 tx/wy 有完整实现(见 /sources 的 userPlaylist 能力位);' +
|
||||
'未登录或平台不支持时返回 404/502。',
|
||||
tags: ['歌单'],
|
||||
params: params({ source: pSource('userPlaylist.getList') }),
|
||||
querystring: qs({ page: pPage, limit: pLimit }),
|
||||
response: responses('UserPlaylistResult'),
|
||||
},
|
||||
}, async(request) => {
|
||||
const { source } = request.params
|
||||
const { page, limit } = request.query
|
||||
const m = getSource(musicSdk, source, 'userPlaylist.getList').userPlaylist
|
||||
try {
|
||||
const list = await m.getList(page, limit)
|
||||
return { source, list: Array.isArray(list) ? list : (list?.list ?? []), page, limit }
|
||||
} catch (err: any) {
|
||||
throw upstream(err.message)
|
||||
}
|
||||
})
|
||||
|
||||
// ---------- 系统 ----------
|
||||
|
||||
fastify.get('/health', {
|
||||
@@ -494,22 +760,24 @@ export const musicRoutes = async(fastify: any) => {
|
||||
platforms: musicSdk.sources.map(({ id, name }) => {
|
||||
const m = musicSdk[id] || {}
|
||||
const qualities = musicSdk.supportQuality?.[id] ?? []
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
qualities,
|
||||
capabilities: {
|
||||
search: Boolean(m.musicSearch),
|
||||
url: Boolean(m.getMusicUrl),
|
||||
lyric: Boolean(m.getLyric),
|
||||
pic: Boolean(m.getPic),
|
||||
boards: Boolean(m.leaderboard),
|
||||
hotSearch: Boolean(m.hotSearch),
|
||||
songList: Boolean(m.songList),
|
||||
singer: Boolean(m.singer),
|
||||
album: Boolean(m.album),
|
||||
},
|
||||
const caps = {
|
||||
search: Boolean(m.musicSearch),
|
||||
url: Boolean(m.getMusicUrl),
|
||||
lyric: Boolean(m.getLyric),
|
||||
pic: Boolean(m.getPic),
|
||||
boards: Boolean(m.leaderboard),
|
||||
hotSearch: Boolean(m.hotSearch),
|
||||
songList: Boolean(m.songList),
|
||||
singer: Boolean(m.singer),
|
||||
album: Boolean(m.album?.getAlbumDetail || m.album?.getAlbumListDetail),
|
||||
albumSearch: Boolean(m.album?.searchAlbum),
|
||||
playlist: Boolean(m.playlist?.getPlaylistDetail),
|
||||
playlistSearch: Boolean(m.playlist?.searchPlaylist),
|
||||
playlistCategories: Boolean(m.playlist?.getCategories),
|
||||
parse: Boolean(m.parse?.parseSong || m.parse?.parseAlbum || m.parse?.parsePlaylist),
|
||||
userPlaylist: Boolean(m.userPlaylist?.getList),
|
||||
}
|
||||
return { id, name, qualities, capabilities: caps }
|
||||
}),
|
||||
}))
|
||||
}
|
||||
+17
-9
@@ -1,19 +1,26 @@
|
||||
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
|
||||
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
|
||||
//
|
||||
// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略时聚合全部平台)
|
||||
// GET /find?name=&singer=&albumName=&interval= 跨平台精确匹配
|
||||
// GET /search/:source/:keyword[/:page] 搜索(source 为平台 id 或 all 聚合)
|
||||
// GET /find/:source/:keyword 跨平台精确匹配
|
||||
// GET /url/:source/:id 播放地址
|
||||
// GET /lyric/:source/:id 歌词
|
||||
// GET /pic/:source/:id 封面
|
||||
// GET /boards/:source 榜单列表
|
||||
// GET /boards/:source/:boardId 榜单详情
|
||||
// GET /boards/:source/:boardId[/:page] 榜单详情
|
||||
// GET /hot-search/:source 热搜词
|
||||
// GET /song-list/:source 歌单列表
|
||||
// GET /song-list/:source[/:page] 歌单列表
|
||||
// GET /singer/:source/:id 歌手信息(kg/tx/wy)
|
||||
// GET /singer/:source/:id/songs 歌手歌曲(kg/tx/wy)
|
||||
// GET /singer/:source/:id/albums 歌手专辑(kg/tx/wy)
|
||||
// GET /album/:source/:id 专辑详情+曲目(kg/kw)
|
||||
// GET /singer/:source/:id/songs[/:page] 歌手歌曲(kg/tx/wy)
|
||||
// GET /singer/:source/:id/albums[/:page] 歌手专辑(kg/tx/wy)
|
||||
// GET /album/:source/:id[/:page] 专辑详情+曲目
|
||||
// GET /album/:source/search/:keyword 专辑搜索
|
||||
// GET /playlist/:source/:id[/:page] 歌单详情+曲目
|
||||
// GET /playlist/:source/search/:keyword 歌单搜索
|
||||
// GET /playlist/:source/categories[/:categoryId] 歌单分类
|
||||
// POST /parse/{song|album|playlist}/:source 链接解析
|
||||
// GET /user-playlist/:source 个人歌单
|
||||
// GET /stream/:source/:id 解密流代理(source: qs 汽水 / migu 咪咕)
|
||||
// GET /sources /health GET /docs /redoc /openapi.json
|
||||
//
|
||||
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
|
||||
@@ -40,8 +47,9 @@ import { redocHtml } from './openapi/ui.ts'
|
||||
const PORT = process.env.PORT || 16688
|
||||
const DOCS_PREFIX = '/docs'
|
||||
const API_TITLE = 'MusicServer 聚合音乐 API'
|
||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)平台。'
|
||||
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单、专辑、歌手、链接解析。' +
|
||||
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ qs(汽水音乐)/ migu(咪咕)/ qian(千千)/ ' +
|
||||
'joox / bili(哔哩哔哩)/ 5sing / apple(Apple Music,仅试听)与有声平台 xm(喜马拉雅)/ me(猫耳FM)/ qt(蜻蜓FM)/ lr(懒人听书)。'
|
||||
|
||||
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
|
||||
|
||||
|
||||
+249
-13
@@ -7,6 +7,7 @@
|
||||
|
||||
import http from 'node:http'
|
||||
import net from 'node:net'
|
||||
import path from 'node:path'
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
// 端口策略:默认在随机空闲端口自起独立实例(避免连到 16688 等常驻实例——
|
||||
@@ -75,13 +76,33 @@ const test = async(name: string, fn: () => Promise<any>) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 测试数据目录:与真实 data/ 隔离,避免 /login/refresh 等用例改写开发者的真实登录态
|
||||
const TEST_DATA_DIR = path.join(process.cwd(), 'tmp', 'test-data')
|
||||
|
||||
// 已启动的全部子实例。无论脚本正常收尾还是中途抛错,退出时统一回收 ——
|
||||
// 否则异常路径会留下监听端口的孤儿 node(历史上曾累积 5 个泄漏实例)
|
||||
const spawned: any[] = []
|
||||
process.on('exit', () => {
|
||||
for (const proc of spawned) {
|
||||
try { proc.kill() } catch { /* 已自行退出 */ }
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 启动本地服务器(指定端口,可覆盖环境变量)并返回进程句柄
|
||||
* 显式关闭外部音源(SOURCE_ENABLED=''):本套用例验证的是 SDK 官方直连通道,
|
||||
* 若继承开发者 .env 的 SOURCE_ENABLED=true,取流会先走 sources/ 脚本链,掩盖 SDK 侧回归。
|
||||
*/
|
||||
const startLocalServer = (port: number, envExtra: Record<string, string> = {}) => new Promise((resolve, reject) => {
|
||||
const proc = spawn(process.execPath, ['src/server.ts'], {
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, PORT: String(port), ...envExtra },
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(port),
|
||||
SOURCE_ENABLED: '',
|
||||
DATA_DIR: TEST_DATA_DIR,
|
||||
...envExtra,
|
||||
},
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
})
|
||||
let stdout = ''
|
||||
@@ -90,16 +111,22 @@ const startLocalServer = (port: number, envExtra: Record<string, string> = {}) =
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error('启动本地服务器超时'))
|
||||
}, 10000)
|
||||
reject(new Error(`启动本地服务器超时(30s)\n--- 子进程输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`))
|
||||
}, 30000)
|
||||
|
||||
const check = () => {
|
||||
// 子进程提前退出(端口占用 / 语法错误等):立即带输出报错,不再空等到超时
|
||||
if (proc.exitCode !== null) {
|
||||
clearTimeout(timer)
|
||||
return reject(new Error(`本地服务器进程已退出(code ${proc.exitCode})\n--- 输出(末 2000 字)---\n${stdout.slice(-2000) || '(无输出)'}`))
|
||||
}
|
||||
// 启用 AUTH_KEY 的实例全路径要求秘钥,探活也须携带
|
||||
const headers = envExtra.AUTH_KEY ? { 'X-Server-Key': envExtra.AUTH_KEY } : {}
|
||||
http.get(`http://localhost:${port}/health`, { headers }, res => {
|
||||
res.resume()
|
||||
if (res.statusCode === 200) {
|
||||
clearTimeout(timer)
|
||||
spawned.push(proc)
|
||||
resolve(proc)
|
||||
} else {
|
||||
setTimeout(check, 200)
|
||||
@@ -123,7 +150,8 @@ if (externalServer) {
|
||||
})
|
||||
const port = PORT || freePort
|
||||
BASE = `http://localhost:${port}`
|
||||
localServer = await startLocalServer(port)
|
||||
// 主实例关闭限流:用例数已超默认阈值(120/min),限流行为由下方独立低阈值实例专项验证
|
||||
localServer = await startLocalServer(port, { RATE_LIMIT: '0' })
|
||||
console.log(`本地服务器已启动(:${port})\n`)
|
||||
}
|
||||
|
||||
@@ -148,12 +176,19 @@ await test('聚合搜索', async() => {
|
||||
// kw 匿名通道 2026-10-01 接入 mobi.s convert_url_with_sign 主通道后:
|
||||
// 免费歌全档位可用,VIP 歌也放行真音频(320k/flac 实测与 N_MINFO 元数据逐字节吻合),
|
||||
// web playUrl 仅作回退(匿名恒 128k)
|
||||
// 酷我 CDN 回吐的 url query 用 $ 顶替 =(?bitrate$320&format$mp3…),非标准分隔符,
|
||||
// 取了 kw 的流就必须确认 SDK 已归正,否则播放器解析不出参数
|
||||
const assertKwUrl = (url: string): string => {
|
||||
if (url.includes('$')) throw new Error(`URL 含未归正的 $ 分隔符: ${url}`)
|
||||
return url.slice(0, 60)
|
||||
}
|
||||
|
||||
await test('URL kw 128k 免费歌', async() => {
|
||||
const r = await get('/url/kw/151826618?quality=128k')
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
if (r.data.via !== undefined) throw new Error(`直接命中不应有 via 字段,实际 ${r.data.via}`)
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => {
|
||||
@@ -161,7 +196,7 @@ await test('URL kw VIP 歌 320k(mobi 通道真音频)', async() => {
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
// mobi 通道严格校验 bitrate==320 才放行,url 必然是 320k 档
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL kw VIP 歌 flac 降级链', async() => {
|
||||
@@ -169,7 +204,7 @@ await test('URL kw VIP 歌 flac 降级链', async() => {
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.url) throw new Error('无 url')
|
||||
// 晴天无 hi-res 档,应逐级降级命中 2000k flac 而非报错
|
||||
return r.data.url.slice(0, 60)
|
||||
return assertKwUrl(r.data.url)
|
||||
})
|
||||
|
||||
await test('URL wy 128k', async() => {
|
||||
@@ -479,6 +514,202 @@ await test('专辑详情 /album/lr', async() => {
|
||||
return `${r.data.info.total} 节`
|
||||
})
|
||||
|
||||
// ---- 新平台(migu/qian/joox/bili/5sing/apple 移植回归) ----
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'bili', '5sing', 'apple']) {
|
||||
await test(`搜索 ${source} 直查`, async() => {
|
||||
const r = await get(`/search/${source}/${encodeURIComponent(source === '5sing' ? '董贞' : '周杰伦')}?limit=5`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 条,首条 ${r.data.list[0].name}`
|
||||
})
|
||||
}
|
||||
|
||||
await test('URL migu(多档候选链)', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const song = r.data.list.find((s: any) => !s.payPlay) ?? r.data.list[0]
|
||||
const u = await get(`/url/migu/${encodeURIComponent(song.songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
const url = String(u.data.url)
|
||||
if (!url.startsWith('http') && !url.includes('/stream/migu/')) throw new Error('url 非法')
|
||||
return `实际 ${u.data.type},${url.slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('URL qian 免费曲', async() => {
|
||||
// qian 免费曲目少,用「SSR」(上游测试同款厂牌)搜到带直链的曲目
|
||||
const r = await get(`/search/qian/${encodeURIComponent('SSR')}?limit=10`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const song = r.data.list.find((s: any) => !s.payPlay)
|
||||
if (!song) throw new Error('未搜到免费曲目')
|
||||
const u = await get(`/url/qian/${encodeURIComponent(song.songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('URL 5sing(档位 sq/hq/lq)', async() => {
|
||||
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
|
||||
if (r.code != 0 || !r.data?.list?.length) throw new Error('搜索失败,无法取流')
|
||||
const u = await get(`/url/5sing/${encodeURIComponent(r.data.list[0].songmid)}?quality=128k`)
|
||||
if (u.code != 0) throw new Error(u.msg)
|
||||
return `实际 ${u.data.type},${String(u.data.url).slice(0, 60)}`
|
||||
})
|
||||
|
||||
await test('歌词 migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const l = await get(`/lyric/migu/${encodeURIComponent(song.songmid)}`)
|
||||
if (l.code != 0) throw new Error(l.msg)
|
||||
if (!l.data?.lyric) throw new Error('空歌词')
|
||||
return l.data.lyric.slice(0, 40)
|
||||
})
|
||||
|
||||
await test('歌词 5sing', async() => {
|
||||
const r = await get(`/search/5sing/${encodeURIComponent('董贞')}?limit=5`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const l = await get(`/lyric/5sing/${encodeURIComponent(song.songmid)}`)
|
||||
if (l.code != 0) throw new Error(l.msg)
|
||||
if (!l.data?.lyric) throw new Error('空歌词')
|
||||
return l.data.lyric.slice(0, 40)
|
||||
})
|
||||
|
||||
await test('封面 migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const song = r.data?.list?.[0]
|
||||
if (!song) throw new Error('搜索失败')
|
||||
const p = await get(`/pic/migu/${encodeURIComponent(song.songmid)}`)
|
||||
if (p.code != 0) throw new Error(p.msg)
|
||||
if (!String(p.data?.url ?? '').startsWith('http')) throw new Error('封面 url 非法')
|
||||
return String(p.data.url).slice(0, 60)
|
||||
})
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'apple']) {
|
||||
await test(`专辑搜索 /album/${source}/search`, async() => {
|
||||
const r = await get(`/album/${source}/search/${encodeURIComponent('Hello')}?limit=3`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空列表')
|
||||
return `${r.data.list.length} 张,首张 ${r.data.list[0].name}`
|
||||
})
|
||||
}
|
||||
|
||||
await test('专辑详情 /album/migu', async() => {
|
||||
const r = await get(`/search/migu/${encodeURIComponent('起风了')}?limit=3`)
|
||||
const albumId = r.data?.list?.find((s: any) => s.albumId)?.albumId
|
||||
if (!albumId) throw new Error('搜索结果未携带 albumId')
|
||||
const a = await get(`/album/migu/${albumId}`)
|
||||
if (a.code != 0) throw new Error(a.msg)
|
||||
if (!a.data?.list?.length) throw new Error('专辑无曲目')
|
||||
return `${a.data.info.name}(${a.data.total} 首)`
|
||||
})
|
||||
|
||||
for (const source of ['migu', 'qian', 'joox', 'apple']) {
|
||||
await test(`歌单分类 /playlist/${source}/categories`, async() => {
|
||||
const r = await get(`/playlist/${source}/categories`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('空分类')
|
||||
return `${r.data.list.length} 个分类`
|
||||
})
|
||||
|
||||
await test(`分类歌单 /playlist/${source}/categories/:id`, async() => {
|
||||
const c = await get(`/playlist/${source}/categories`)
|
||||
const cat = c.data.list[0]
|
||||
if (!cat) throw new Error('无分类')
|
||||
const r = await get(`/playlist/${source}/categories/${encodeURIComponent(cat.id)}?limit=3`)
|
||||
if (r.code != 0) throw new Error(r.msg)
|
||||
if (!r.data?.list?.length) throw new Error('分类下无歌单')
|
||||
return `${r.data.list.length} 个歌单`
|
||||
})
|
||||
}
|
||||
|
||||
await test('歌单详情 /playlist/migu', async() => {
|
||||
const c = await get('/playlist/migu/categories')
|
||||
const cat = c.data.list[0]
|
||||
const pl = await get(`/playlist/migu/categories/${encodeURIComponent(cat.id)}?limit=3`)
|
||||
const id = pl.data?.list?.[0]?.id
|
||||
if (!id) throw new Error('未取到歌单 id')
|
||||
const d = await get(`/playlist/migu/${id}?limit=5`)
|
||||
if (d.code != 0) throw new Error(d.msg)
|
||||
if (!d.data?.list?.length) throw new Error('歌单无曲目')
|
||||
return `${d.data.total} 首,本页 ${d.data.list.length} 首`
|
||||
})
|
||||
|
||||
await test('歌单详情 /playlist/bili(合集)', async() => {
|
||||
const s = await get(`/playlist/bili/search/${encodeURIComponent('周杰伦')}?limit=3`)
|
||||
const id = s.data?.list?.[0]?.id
|
||||
if (!id) throw new Error('搜索无歌单')
|
||||
const d = await get(`/playlist/bili/${encodeURIComponent(id)}?limit=5`)
|
||||
if (d.code != 0) throw new Error(d.msg)
|
||||
if (!d.data?.list?.length) throw new Error('歌单无曲目')
|
||||
return `${d.data.total} 首`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/song/migu', async() => {
|
||||
const r = await postJson('/parse/song/migu', { link: 'https://music.migu.cn/v3/music/song/600913000002601091' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.song?.songmid) throw new Error('缺少 song')
|
||||
return `${r.body.data.song.name} / ${r.body.data.song.singer}`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/playlist/qian', async() => {
|
||||
const r = await postJson('/parse/playlist/qian', { link: 'https://music.91q.com/songlist/309735' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.list?.length) throw new Error('无曲目')
|
||||
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/album/apple', async() => {
|
||||
const r = await postJson('/parse/album/apple', { link: 'https://music.apple.com/us/album/the-life-of-a-showgirl-the-encore/6814997249' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.list?.length) throw new Error('无曲目')
|
||||
return `${r.body.data.playlist?.name ?? ''}(${r.body.data.total} 首)`
|
||||
})
|
||||
|
||||
await test('链接解析 不支持的平台 → 404', async() => {
|
||||
// qs 无 parse 实现(以 /sources 的 parse 能力位为准)。
|
||||
// 原先此处断言 kw 返回 404 —— 但 kw 的 parse 早已实现(sdk/kw/parse.ts),
|
||||
// 该断言过期后恒失败,导致 pnpm test 永远无法全绿。
|
||||
const r = await postJson('/parse/song/qs', { link: 'https://music.douyin.com/qishui/share/track?track_id=1' })
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
return 'qs 未实现 parse(按上游)'
|
||||
})
|
||||
|
||||
await test('链接解析 /parse/song/kw(正向)', async() => {
|
||||
const r = await postJson('/parse/song/kw', { link: 'https://www.kuwo.cn/play_detail/28423286' })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
if (!r.body.data?.song?.songmid) throw new Error('未返回 song.songmid')
|
||||
return `kw 解析成功: ${r.body.data.song.name}`
|
||||
})
|
||||
|
||||
await test('扫码二维码创建 POST /login/bili/qr', async() => {
|
||||
const r = await postRaw('/login/bili/qr')
|
||||
const body = JSON.parse(r.body)
|
||||
if (r.status !== 200 || body.code != 0 || !body.data?.key) throw new Error(r.body.slice(0, 120))
|
||||
return `key=${String(body.data.key).slice(0, 12)}…`
|
||||
})
|
||||
|
||||
await test('扫码轮询 POST /login/bili/qr/check', async() => {
|
||||
const created = JSON.parse((await postRaw('/login/bili/qr')).body)
|
||||
const key = created.data?.key
|
||||
if (!key) throw new Error('未取到 key')
|
||||
const r = await postJson('/login/bili/qr/check', { key })
|
||||
if (r.status !== 200 || r.body?.code != 0) throw new Error(JSON.stringify(r.body).slice(0, 120))
|
||||
return `status=${r.body.data.status} code=${r.body.data.code}`
|
||||
})
|
||||
|
||||
await test('个人歌单 未支持的平台 → 502', async() => {
|
||||
const r = await getRaw('/user-playlist/5sing')
|
||||
if (r.status !== 502 && r.status !== 404) throw new Error(`期望 502/404,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg?.slice(0, 60)
|
||||
})
|
||||
|
||||
await test('咪咕解密流代理 无票据 → 404', async() => {
|
||||
const r = await getRaw('/stream/migu/ffffffffffffffffffffffffffffffff')
|
||||
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
|
||||
return JSON.parse(r.body).msg?.slice(0, 50)
|
||||
})
|
||||
|
||||
await test('参数校验 422(非法页码)', async() => {
|
||||
const r = await getRaw(`/search/kw/${encodeURIComponent('稻香')}/0`)
|
||||
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
|
||||
@@ -594,6 +825,8 @@ await test('扫码二维码创建 POST /login/qs/qr', async() => {
|
||||
const r = await postJson('/login/qs/qr', {})
|
||||
if (r.status !== 200 || r.body?.code !== 0) throw new Error(r.body?.msg ?? `HTTP ${r.status}`)
|
||||
if (!r.body.data?.token || !r.body.data?.qrimg?.startsWith('data:image/')) throw new Error('响应不完整')
|
||||
// 二维码必须是官方 PC 客户端同款扫码页(旧的 ucenter_web/app/sdk-next 已 404,抖音扫出「无法访问页面」)
|
||||
if (!String(r.body.data?.scanUrl ?? '').includes('/light/invoke/scan_login')) throw new Error(`二维码内容非官方扫码页: ${r.body.data?.scanUrl}`)
|
||||
return `scanApp=${r.body.data.scanApp || '?'}`
|
||||
})
|
||||
|
||||
@@ -655,12 +888,15 @@ if (!externalServer && !process.env.AUTH_KEY) {
|
||||
if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`)
|
||||
return 'ok'
|
||||
})
|
||||
// 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性
|
||||
for (const path of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) {
|
||||
const r = await authFetch(path)
|
||||
if (r.status !== 401) throw new Error(`${path} 期望 401,实际 ${r.status}(应全路径要求秘钥)`)
|
||||
}
|
||||
await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => 'ok')
|
||||
// 防扫描:全部页面(含文档/健康检查/不存在的路径)无秘钥一律 401,无法探测接口存在性。
|
||||
// 断言必须包在 test() 内 —— 裸抛错会中断整个脚本,跳过后续用例与实例回收(留下孤儿进程)
|
||||
await test('AUTH_KEY 全路径防扫(文档/health/404 均 401)', async() => {
|
||||
for (const p of ['/health', '/docs', '/openapi.json', '/redoc', '/not-exist-path', '/login/status']) {
|
||||
const r = await authFetch(p)
|
||||
if (r.status !== 401) throw new Error(`${p} 期望 401,实际 ${r.status}(应全路径要求秘钥)`)
|
||||
}
|
||||
return '6 条路径均 401'
|
||||
})
|
||||
// /stream/* 流代理:支持 X-Server-Key 头,或短时签名 ?exp=&sig=(不落裸秘钥到 URL);裸 ?key= 已废弃
|
||||
await test('AUTH_KEY 流代理签名机制', async() => {
|
||||
// 裸秘钥 ?key= 已不生效
|
||||
|
||||
Reference in new issue
Block a user