From c1c8aa4b9438de03439a910a21bf2db841e2b24d Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:07:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor(routes)!:=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=97=A7=E7=89=88=20query=20=E9=A3=8E=E6=A0=BC=E8=B7=AF?= =?UTF-8?q?=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/API.md | 28 +++++----- src/routes.js | 134 +++++----------------------------------------- src/server.js | 19 ++++--- src/smoke-test.js | 57 +++++++------------- 5 files changed, 55 insertions(+), 185 deletions(-) diff --git a/README.md b/README.md index 4863799..17d132a 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ npm test # 运行冒烟测试(自动启动/关闭本地服务器) ## HTTP 接口 -规范风格使用路径参数;旧版 query 风格(如 `/url?source=kw&id=228908`)保留兼容,已标记 deprecated。 +接口统一为路径参数风格(`/search`、`/find` 搜索类使用 query 参数)。 | 接口 | 说明 | | --- | --- | diff --git a/docs/API.md b/docs/API.md index e3a20fd..6ec4009 100644 --- a/docs/API.md +++ b/docs/API.md @@ -19,7 +19,7 @@ - `405` 方法不允许(响应带 `Allow` 头) - `502` 上游平台请求失败 - `500` 服务器内部错误 -- 每个资源接口同时提供**路径参数风格**(规范)与 **query 风格**(旧版兼容,文档中标记 deprecated)。 +- 资源类接口统一为**路径参数风格**;`/search`、`/find` 搜索类接口使用 query 参数。 - 平台歌曲 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 | `/lyric/:source/:id` | `/lyric?source=&id=` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) | -| GET | `/pic/:source/:id` | `/pic?source=&id=` | 封面;可带 `albumId`(kg 需要) | +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/url/:source/:id` | 播放地址;query 可带 `quality`(128k/320k/flac/flac24bit,默认 128k)与 `albumId`(kg 需要) | +| GET | `/lyric/:source/:id` | 歌词;可带 `name`/`interval`(部分平台搜索歌词时需要) | +| GET | `/pic/:source/:id` | 封面;可带 `albumId`(kg 需要) | ### 榜单 -| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | -| --- | --- | --- | --- | -| GET | `/boards/:source` | `/boards?source=` | 榜单列表 | -| GET | `/boards/:source/:boardId` | `/board?source=&id=` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) | +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/boards/:source` | 榜单列表 | +| GET | `/boards/:source/:boardId` | 榜单详情;可带 `page`。`boardId` 兼容榜单列表的带前缀 `id`(`kw__93`)与裸 `bangid`(`93`) | ### 歌单 & 热搜 -| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | -| --- | --- | --- | --- | -| GET | `/hot-search/:source` | `/hotSearch?source=` | 热搜词 | -| GET | `/song-list/:source` | `/songList?source=` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 | +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/hot-search/:source` | 热搜词 | +| GET | `/song-list/:source` | 歌单列表;可带 `page`/`sortId`/`tagId`。`sortId`/`tagId` 各平台词汇不同(kw `new`/`hot`、tx `5`/`2`、kw 标签为 `173-digest` 形态等),`sortId` 省略时用平台默认 | ## 响应示例 diff --git a/src/routes.js b/src/routes.js index 66b3736..c6faecd 100644 --- a/src/routes.js +++ b/src/routes.js @@ -124,13 +124,11 @@ export const musicRoutes = async(fastify) => { } }) - // ---------- 歌曲资源(路径参数风格 + 旧 query 风格别名) ---------- + // ---------- 歌曲资源(路径参数风格) ---------- const urlHandler = async(request) => { - const q = request.params.id - ? { source: request.params.source, id: request.params.id, ...request.query } - : request.query - const { source, id, quality, albumId } = q + const { source, id } = request.params + const { quality, albumId } = request.query const m = getSource(musicSdk, source, 'getMusicUrl') const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} } let result @@ -161,26 +159,9 @@ export const musicRoutes = async(fastify) => { }, }, 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 q = request.params.id - ? { source: request.params.source, id: request.params.id, ...request.query } - : request.query - const { source, id, name, interval } = q + const { source, id } = request.params + const { name, interval } = request.query const m = getSource(musicSdk, source, 'getLyric') let result try { @@ -206,26 +187,9 @@ export const musicRoutes = async(fastify) => { }, }, 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 q = request.params.id - ? { source: request.params.source, id: request.params.id, ...request.query } - : request.query - const { source, id, albumId } = q + const { source, id } = request.params + const { albumId } = request.query const m = getSource(musicSdk, source, 'getPic') let result try { @@ -248,20 +212,6 @@ export const musicRoutes = async(fastify) => { }, }, 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', { @@ -276,19 +226,6 @@ export const musicRoutes = async(fastify) => { 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 m = getSource(musicSdk, source, 'leaderboard') // 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化 @@ -311,22 +248,6 @@ export const musicRoutes = async(fastify) => { 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', { @@ -341,19 +262,6 @@ export const musicRoutes = async(fastify) => { 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 m = getSource(musicSdk, source, 'songList') // sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序 @@ -365,34 +273,16 @@ export const musicRoutes = async(fastify) => { summary: '获取歌单列表', tags: ['歌单'], params: params({ source: pSource('songList') }), - querystring: qs({ - page: pPage, - sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' }, - tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' }, - }), - response: responses('SongListResult'), - }, - }, async(request) => { - const { page, sortId, tagId } = request.query - 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']), + sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' }, + tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' }, + }), response: responses('SongListResult'), }, }, async(request) => { - const { source, page, sortId, tagId } = request.query - return songListHandler(source, page, sortId, tagId) + const { page, sortId, tagId } = request.query + return songListHandler(request.params.source, page, sortId, tagId) }) // ---------- 系统 ---------- diff --git a/src/server.js b/src/server.js index 80f76af..df67761 100644 --- a/src/server.js +++ b/src/server.js @@ -1,16 +1,15 @@ // HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API // 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动) // -// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容) -// GET /search (同左) -// GET /find (同左) -// GET /url/:source/:id GET /url?source=&id= -// GET /lyric/:source/:id GET /lyric?source=&id= -// GET /pic/:source/:id GET /pic?source=&id= -// GET /boards/:source GET /boards?source= -// GET /boards/:source/:boardId GET /board?source=&id= -// GET /hot-search/:source GET /hotSearch?source= -// GET /song-list/:source GET /songList?source= +// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略时聚合全部平台) +// GET /find?name=&singer=&albumName=&interval= 跨平台精确匹配 +// GET /url/:source/:id 播放地址 +// GET /lyric/:source/:id 歌词 +// GET /pic/:source/:id 封面 +// GET /boards/:source 榜单列表 +// GET /boards/:source/:boardId 榜单详情 +// GET /hot-search/:source 热搜词 +// GET /song-list/:source 歌单列表 // GET /sources /health GET /docs /redoc /openapi.json // // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 diff --git a/src/smoke-test.js b/src/smoke-test.js index 6a16292..8786dc6 100644 --- a/src/smoke-test.js +++ b/src/smoke-test.js @@ -2,7 +2,8 @@ // 运行:node src/smoke-test.js // // 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、 -// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、文档页 +// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、 +// 文档页、旧 query 路由移除回归 import http from 'node:http' import { spawn } from 'node:child_process' @@ -124,23 +125,23 @@ await test('聚合搜索', async() => { return `${r.data.list.length} 条结果` }) -// URL(用已知歌曲 id) +// URL(用已知歌曲 id,路径参数风格) 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.data?.url) throw new Error('无 url') return r.data.url.slice(0, 60) }) 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.data?.url) throw new Error('无 url') return r.data.url.slice(0, 60) }) 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.data?.url) throw new Error('无 url') return r.data.url.slice(0, 60) @@ -148,7 +149,7 @@ await test('URL tx 320k', 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.data?.lyric) throw new Error('无歌词') return `${r.data.lyric.slice(0, 30)}...` @@ -156,7 +157,7 @@ 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.data?.url) throw new Error('无 url') return r.data.url.slice(0, 60) @@ -165,7 +166,7 @@ await test('封面 kw', async() => { // 榜单 & 热搜 for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) { 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.data?.list?.length) throw new Error('空列表') return `${r.data.list.length} 个榜单` @@ -173,13 +174,13 @@ for (const source of ['kw', 'kg', 'tx', 'wy', 'mg']) { } 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.data?.list?.length) throw new Error('空列表') return r.data.list[0] }) -// ---- 标准 HTTP 语义与文档(新版路由) ---- +// ---- 标准 HTTP 语义与文档 ---- await test('平台列表 /sources', async() => { const r = await get('/sources') @@ -190,34 +191,6 @@ await test('平台列表 /sources', async() => { 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() => { // 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid) const r = await get('/boards/kw/kw__93?page=1') @@ -267,6 +240,14 @@ await test('404 未知接口', async() => { 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() => { const r = await postRaw('/search') if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)