refactor(routes)!: 移除旧版 query 风格路由

This commit is contained in:
snowy-yang committed 2026-09-16 21:07:01 +08:00
1 parent 10de5582c7
commit c1c8aa4b94
5 files changed
+50 -180

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ npm test # 运行冒烟测试(自动启动/关闭本地服务器)
## HTTP 接口 ## HTTP 接口
规范风格使用路径参数;旧版 query 风格(如 `/url?source=kw&id=228908`)保留兼容,已标记 deprecated。 接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。
| 接口 | 说明 | | 接口 | 说明 |
| --- | --- | | --- | --- |
+14 -14
View File
@@ -19,7 +19,7 @@
- `405` 方法不允许(响应带 `Allow` 头) - `405` 方法不允许(响应带 `Allow` 头)
- `502` 上游平台请求失败 - `502` 上游平台请求失败
- `500` 服务器内部错误 - `500` 服务器内部错误
- 每个资源接口同时提供**路径参数风格**(规范)与 **query 风格**(旧版兼容,文档中标记 deprecated)。 - 资源类接口统一为**路径参数风格**;`/search`、`/find` 搜索类接口使用 query 参数。
- 平台歌曲 id 形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`(mg 内部自动换取 contentId)。 - 平台歌曲 id 形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`(mg 内部自动换取 contentId)。
## 接口速查 ## 接口速查
@@ -44,25 +44,25 @@
### 歌曲资源 ### 歌曲资源
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- |
| GET | `/url/:source/:id` | `/url?source=&id=` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) | | GET | `/url/:source/:id` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) |
| GET | `/lyric/:source/:id` | `/lyric?source=&id=` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) | | GET | `/lyric/:source/:id` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) |
| GET | `/pic/:source/:id` | `/pic?source=&id=` | 封面;可带 `albumId`(kg 需要) | | GET | `/pic/:source/:id` | 封面;可带 `albumId`(kg 需要) |
### 榜单 ### 榜单
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- |
| GET | `/boards/:source` | `/boards?source=` | 榜单列表 | | GET | `/boards/:source` | 榜单列表 |
| GET | `/boards/:source/:boardId` | `/board?source=&id=` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) | | GET | `/boards/:source/:boardId` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) |
### 歌单 & 热搜 ### 歌单 & 热搜
| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | | 方法 | 路径 | 说明 |
| --- | --- | --- | --- | | --- | --- | --- |
| GET | `/hot-search/:source` | `/hotSearch?source=` | 热搜词 | | GET | `/hot-search/:source` | 热搜词 |
| GET | `/song-list/:source` | `/songList?source=` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 | | GET | `/song-list/:source` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 |
## 响应示例 ## 响应示例
+7 -117
View File
@@ -124,13 +124,11 @@ export const musicRoutes = async(fastify) => {
} }
}) })
// ---------- 歌曲资源(路径参数风格 + 旧 query 风格别名) ---------- // ---------- 歌曲资源(路径参数风格) ----------
const urlHandler = async(request) => { const urlHandler = async(request) => {
const q = request.params.id const { source, id } = request.params
? { source: request.params.source, id: request.params.id, ...request.query } const { quality, albumId } = request.query
: request.query
const { source, id, quality, albumId } = q
const m = getSource(musicSdk, source, 'getMusicUrl') const m = getSource(musicSdk, source, 'getMusicUrl')
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} } const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
let result let result
@@ -161,26 +159,9 @@ export const musicRoutes = async(fastify) => {
}, },
}, urlHandler) }, urlHandler)
fastify.get('/url', {
schema: {
summary: '获取播放地址(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('url'),
id: pId,
quality: pQuality,
albumId: { type: 'string', description: '专辑 id' },
}, ['source', 'id']),
response: responses('MusicUrlResult'),
},
}, urlHandler)
const lyricHandler = async(request) => { const lyricHandler = async(request) => {
const q = request.params.id const { source, id } = request.params
? { source: request.params.source, id: request.params.id, ...request.query } const { name, interval } = request.query
: request.query
const { source, id, name, interval } = q
const m = getSource(musicSdk, source, 'getLyric') const m = getSource(musicSdk, source, 'getLyric')
let result let result
try { try {
@@ -206,26 +187,9 @@ export const musicRoutes = async(fastify) => {
}, },
}, lyricHandler) }, lyricHandler)
fastify.get('/lyric', {
schema: {
summary: '获取歌词(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('lyric'),
id: pId,
name: { type: 'string', description: '歌名' },
interval: { type: 'string', description: '时长(mm:ss)' },
}, ['source', 'id']),
response: responses('LyricInfo'),
},
}, lyricHandler)
const picHandler = async(request) => { const picHandler = async(request) => {
const q = request.params.id const { source, id } = request.params
? { source: request.params.source, id: request.params.id, ...request.query } const { albumId } = request.query
: request.query
const { source, id, albumId } = q
const m = getSource(musicSdk, source, 'getPic') const m = getSource(musicSdk, source, 'getPic')
let result let result
try { try {
@@ -248,20 +212,6 @@ export const musicRoutes = async(fastify) => {
}, },
}, picHandler) }, picHandler)
fastify.get('/pic', {
schema: {
summary: '获取封面(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('pic'),
id: pId,
albumId: { type: 'string', description: '专辑 id' },
}, ['source', 'id']),
response: responses('PicResult'),
},
}, picHandler)
// ---------- 榜单 ---------- // ---------- 榜单 ----------
fastify.get('/boards/:source', { fastify.get('/boards/:source', {
@@ -276,19 +226,6 @@ export const musicRoutes = async(fastify) => {
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) }) return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
}) })
fastify.get('/boards', {
schema: {
summary: '获取榜单列表(旧版 query 风格)',
deprecated: true,
tags: ['榜单'],
querystring: qs({ source: pSource('boards') }, ['source']),
response: responses('BoardListResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.query.source, 'leaderboard')
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
})
const boardHandler = (source, id, page) => { const boardHandler = (source, id, page) => {
const m = getSource(musicSdk, source, 'leaderboard') const m = getSource(musicSdk, source, 'leaderboard')
// 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化 // 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
@@ -311,22 +248,6 @@ export const musicRoutes = async(fastify) => {
request.params.source, request.params.boardId, request.query.page, request.params.source, request.params.boardId, request.query.page,
)) ))
fastify.get('/board', {
schema: {
summary: '获取榜单详情(旧版 query 风格)',
deprecated: true,
tags: ['榜单'],
querystring: qs({
source: pSource('boards'),
id: { type: 'string', description: '榜单 id' },
page: pPage,
}, ['source', 'id']),
response: responses('BoardDetailResult'),
},
}, async(request) => boardHandler(
request.query.source, request.query.id, request.query.page,
))
// ---------- 歌单 & 热搜 ---------- // ---------- 歌单 & 热搜 ----------
fastify.get('/hot-search/:source', { fastify.get('/hot-search/:source', {
@@ -341,19 +262,6 @@ export const musicRoutes = async(fastify) => {
return m.hotSearch.getList().catch(err => { throw upstream(err.message) }) return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
}) })
fastify.get('/hotSearch', {
schema: {
summary: '获取热搜词(旧版 query 风格)',
deprecated: true,
tags: ['歌单'],
querystring: qs({ source: pSource('hotSearch') }, ['source']),
response: responses('HotSearchResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.query.source, 'hotSearch')
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
})
const songListHandler = (source, page, sortId, tagId) => { const songListHandler = (source, page, sortId, tagId) => {
const m = getSource(musicSdk, source, 'songList') const m = getSource(musicSdk, source, 'songList')
// sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序 // sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序
@@ -377,24 +285,6 @@ export const musicRoutes = async(fastify) => {
return songListHandler(request.params.source, page, sortId, tagId) return songListHandler(request.params.source, page, sortId, tagId)
}) })
fastify.get('/songList', {
schema: {
summary: '获取歌单列表(旧版 query 风格)',
deprecated: true,
tags: ['歌单'],
querystring: qs({
source: pSource('songList'),
page: pPage,
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,省略时用平台默认)' },
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串)' },
}, ['source']),
response: responses('SongListResult'),
},
}, async(request) => {
const { source, page, sortId, tagId } = request.query
return songListHandler(source, page, sortId, tagId)
})
// ---------- 系统 ---------- // ---------- 系统 ----------
fastify.get('/health', { fastify.get('/health', {
+9 -10
View File
@@ -1,16 +1,15 @@
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API // HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动) // 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
// //
// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容) // GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略时聚合全部平台)
// GET /search (同左) // GET /find?name=&singer=&albumName=&interval= 跨平台精确匹配
// GET /find (同左) // GET /url/:source/:id 播放地址
// GET /url/:source/:id GET /url?source=&id= // GET /lyric/:source/:id 歌词
// GET /lyric/:source/:id GET /lyric?source=&id= // GET /pic/:source/:id 封面
// GET /pic/:source/:id GET /pic?source=&id= // GET /boards/:source 榜单列表
// GET /boards/:source GET /boards?source= // GET /boards/:source/:boardId 榜单详情
// GET /boards/:source/:boardId GET /board?source=&id= // GET /hot-search/:source 热搜词
// GET /hot-search/:source GET /hotSearch?source= // GET /song-list/:source 歌单列表
// GET /song-list/:source GET /songList?source=
// GET /sources /health GET /docs /redoc /openapi.json // GET /sources /health GET /docs /redoc /openapi.json
// //
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
+19 -38
View File
@@ -2,7 +2,8 @@
// 运行:node src/smoke-test.js // 运行:node src/smoke-test.js
// //
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、 // 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、文档页 // 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、
// 文档页、旧 query 路由移除回归
import http from 'node:http' import http from 'node:http'
import { spawn } from 'node:child_process' import { spawn } from 'node:child_process'
@@ -124,23 +125,23 @@ await test('聚合搜索', async() => {
return `${r.data.list.length} 条结果` return `${r.data.list.length} 条结果`
}) })
// URL(用已知歌曲 id) // URL(用已知歌曲 id,路径参数风格)
await test('URL kw 320k', async() => { await test('URL kw 320k', async() => {
const r = await get('/url?source=kw&id=28423286&quality=320k') const r = await get('/url/kw/28423286?quality=320k')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url') if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
await test('URL wy 128k', async() => { await test('URL wy 128k', async() => {
const r = await get('/url?source=wy&id=3357698666&quality=128k') const r = await get('/url/wy/3357698666?quality=128k')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url') if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
}) })
await test('URL tx 320k', async() => { await test('URL tx 320k', async() => {
const r = await get('/url?source=tx&id=003C9fBv2K4x8b&quality=320k') const r = await get('/url/tx/003C9fBv2K4x8b?quality=320k')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url') if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
@@ -148,7 +149,7 @@ await test('URL tx 320k', async() => {
// 歌词 // 歌词
await test('歌词 kw', async() => { await test('歌词 kw', async() => {
const r = await get('/lyric?source=kw&id=28423286') const r = await get('/lyric/kw/28423286')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.lyric) throw new Error('无歌词') if (!r.data?.lyric) throw new Error('无歌词')
return `${r.data.lyric.slice(0, 30)}...` return `${r.data.lyric.slice(0, 30)}...`
@@ -156,7 +157,7 @@ await test('歌词 kw', async() => {
// 封面 // 封面
await test('封面 kw', async() => { await test('封面 kw', async() => {
const r = await get('/pic?source=kw&id=28423286') const r = await get('/pic/kw/28423286')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url') if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60) return r.data.url.slice(0, 60)
@@ -165,7 +166,7 @@ await test('封面 kw', async() => {
// 榜单 & 热搜 // 榜单 & 热搜
for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) { for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
await test(`榜单 ${source}`, async() => { await test(`榜单 ${source}`, async() => {
const r = await get(`/boards?source=${source}`) const r = await get(`/boards/${source}`)
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 个榜单` return `${r.data.list.length} 个榜单`
@@ -173,13 +174,13 @@ for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) {
} }
await test('热搜 kw', async() => { await test('热搜 kw', async() => {
const r = await get('/hotSearch?source=kw') const r = await get('/hot-search/kw')
if (r.code != 0) throw new Error(r.msg) if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表') if (!r.data?.list?.length) throw new Error('空列表')
return r.data.list[0] return r.data.list[0]
}) })
// ---- 标准 HTTP 语义与文档(新版路由) ---- // ---- 标准 HTTP 语义与文档 ----
await test('平台列表 /sources', async() => { await test('平台列表 /sources', async() => {
const r = await get('/sources') const r = await get('/sources')
@@ -190,34 +191,6 @@ await test('平台列表 /sources', async() => {
return `${r.data.platforms.length} 个平台` return `${r.data.platforms.length} 个平台`
}) })
await test('URL 路径风格 /url/kw/:id', async() => {
const r = await get('/url/kw/28423286?quality=320k')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
await test('歌词 路径风格 /lyric/kw/:id', async() => {
const r = await get('/lyric/kw/28423286')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.lyric) throw new Error('无歌词')
return `${r.data.lyric.slice(0, 30)}...`
})
await test('榜单 路径风格 /boards/:source', async() => {
const r = await get('/boards/kg')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 个榜单`
})
await test('封面 路径风格 /pic/kw/:id', async() => {
const r = await get('/pic/kw/28423286')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => { await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid) // 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
const r = await get('/boards/kw/kw__93?page=1') const r = await get('/boards/kw/kw__93?page=1')
@@ -267,6 +240,14 @@ await test('404 未知接口', async() => {
return JSON.parse(r.body).msg return JSON.parse(r.body).msg
}) })
await test('旧 query 路由已移除(404)', async() => {
const r = await getRaw('/url?source=kw&id=28423286')
if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`)
const body = JSON.parse(r.body)
if (!/接口不存在/.test(body.msg)) throw new Error(`响应异常: ${body.msg}`)
return body.msg
})
await test('405 方法不允许(静态路由)', async() => { await test('405 方法不允许(静态路由)', async() => {
const r = await postRaw('/search') const r = await postRaw('/search')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`) if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)