From 2b4bb16e0220e1a96ed00da75c4f9d0db68d47cf Mon Sep 17 00:00:00 2001 From: snowy-yang <102860110+snowy-yang@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:56:06 +0800 Subject: [PATCH] =?UTF-8?q?refactor(server):=20=E8=BF=81=E7=A7=BB=E5=88=B0?= =?UTF-8?q?=20Fastify=205=20+=20=E5=AE=8C=E5=96=84=E6=96=87=E6=A1=A3?= =?UTF-8?q?=E4=B8=8E=E8=B7=AF=E7=94=B1=E5=8F=82=E6=95=B0=E9=80=82=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 + README.md | 42 +- docs/API.md | 119 ++--- package.json | 4 + src/http/api-error.js | 3 - src/http/router.js | 53 --- src/openapi/{spec.js => components.js} | 127 ++---- src/openapi/ui.js | 39 +- src/routes.js | 590 ++++++++++++------------- src/server.js | 311 ++++++------- src/smoke-test.js | 56 ++- tmp/check-openapi.mjs | 25 -- tmp/reproduce-lx-source-v5.mjs | 139 ------ tmp/test-lx-source-ivm.mjs | 81 ---- tmp/trace-strings.json | 261 ----------- tmp/verify-real-network.mjs | 10 - 16 files changed, 615 insertions(+), 1249 deletions(-) delete mode 100644 src/http/router.js rename src/openapi/{spec.js => components.js} (61%) delete mode 100644 tmp/check-openapi.mjs delete mode 100644 tmp/reproduce-lx-source-v5.mjs delete mode 100644 tmp/test-lx-source-ivm.mjs delete mode 100644 tmp/trace-strings.json delete mode 100644 tmp/verify-real-network.mjs diff --git a/.gitignore b/.gitignore index 9e09d50..97cdaec 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ pnpm-lock.yaml # Runtime *.log logs/ +tmp/ .env .env.local .env.*.local @@ -15,6 +16,9 @@ logs/ .DS_Store Thumbs.db +# ZCode 本地目录 +.zcode/ + # IDE .vscode/ .idea/ diff --git a/README.md b/README.md index 935535a..8c18687 100644 --- a/README.md +++ b/README.md @@ -12,26 +12,30 @@ ```bash npm install -npm start # 默认端口 3300,可用 PORT 环境变量修改 +npm start # 默认端口 16688,可用 PORT 环境变量修改 npm test # 运行冒烟测试(自动启动/关闭本地服务器) ``` ## HTTP 接口 +规范风格使用路径参数;旧版 query 风格(如 `/url?source=kw&id=228908`)保留兼容,已标记 deprecated。 + | 接口 | 说明 | | --- | --- | | `GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20` | 搜索歌曲(source 省略则聚合所有平台) | | `GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx` | 精确匹配搜索(跨平台去重排序) | -| `GET /url?source=kw&id=228908&quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) | -| `GET /lyric?source=kw&id=228908` | 获取歌词(返回 lyric/tlyric/lxlyric) | -| `GET /pic?source=kw&id=228908` | 获取封面 | -| `GET /boards?source=kw` | 榜单列表 | -| `GET /board?source=kw&id=93&page=1` | 榜单详情 | -| `GET /hotSearch?source=kw` | 热搜列表 | -| `GET /songList?source=kw&page=1` | 歌单列表 | +| `GET /url/:source/:id?quality=320k` | 获取播放地址(quality: 128k/320k/flac/flac24bit) | +| `GET /lyric/:source/:id` | 获取歌词(返回 lyric/tlyric/lxlyric) | +| `GET /pic/:source/:id` | 获取封面 | +| `GET /boards/:source` | 榜单列表 | +| `GET /boards/:source/:boardId` | 榜单详情 | +| `GET /hot-search/:source` | 热搜列表 | +| `GET /song-list/:source?page=1` | 歌单列表 | +| `GET /sources` | 平台列表(音质与能力) | | `GET /health` | 健康检查 | +| `GET /docs` `/redoc` `/openapi.json` | 接口文档(Swagger UI / ReDoc / OpenAPI 3.1) | -统一返回格式:`{ code: 0, data: ... }` 成功;`{ code: 非0, msg: ... }` 失败。 +统一返回格式:`{ code: 0, data: ... }` 成功;`{ code: 非0, msg: ... }` 失败。状态码语义:422 参数校验失败、404 接口或平台不存在、405 方法不允许(带 Allow 头)、502 上游平台失败。 所有接口默认返回 `Access-Control-Allow-Origin: *`,可直接在浏览器/前端调用。 @@ -45,10 +49,18 @@ npm test # 运行冒烟测试(自动启动/关闭本地服务器) ``` src/ - server.js HTTP 服务器入口(npm start 实际启动文件) - index.js SDK 根模块(工具函数聚合导出) + server.js HTTP 服务器入口(npm start 实际启动文件,Fastify) + routes.js 业务路由(JSON Schema 校验 + OpenAPI 元数据) smoke-test.js 冒烟测试(自动启动本地服务器) + index.js SDK 根模块(工具函数聚合导出) + http/ HTTP 层 + api-error.js 业务错误类型(404/502 等 → 统一 JSON 响应) + openapi/ 文档层 + components.js OpenAPI 共享 schema(与路由校验同源) + ui.js ReDoc 页面模板 apis/ 各平台 URL 直连实现(kw/kg/tx/wy/mg/bd) + lx-source.js lx 聚合源懒加载入口 + lx-source-ivm.js isolated-vm 隔离执行器 common/ 公共模块 request.js HTTP 请求(可取消、自动 JSON 解析、gzip/deflate 解压) utils.js 工具函数(sizeFormate/formatPlayTime/dateFormat 等) @@ -58,17 +70,17 @@ src/ lyricDecode.js 酷我/QQ 歌词解码(替代原 Electron 主进程处理器) qrcDecode.js QQ 歌词 3DES 解密(纯 JS 移植) lyricUtils/kg.js 酷狗 krc 歌词解码 -musicSdk/ 洛雪音乐聚合 SDK(搜索/歌词/榜单等核心逻辑) +musicSdk/ 洛雪音乐聚合 SDK(搜索/歌词/榜单等核心逻辑,TypeScript 源码) index.js 平台聚合入口 request.js SDK 请求模块(转接 src/common/request.js) message.js SDK 消息模块(转接 src/common/message.js) ``` +> TypeScript 源码(`src/apis/index.ts`、`musicSdk/**/*.ts`)经 `npm run build`(tsc)就地发射为 `.js` 产物后运行。 + ## 关于 lx-music-source-v5.js -项目目录下的 `lx-music-source-v5.js` 是第三方混淆音源脚本。由于内容不可审计,且 Node.js `vm` 不是安全沙箱,服务器代码中**不再直接加载或执行该脚本**,也不依赖它获取播放地址。 - -如需复用其服务端能力,需要其提供去混淆源码或完整 API 文档。当前 `docs/lx-source-signing.md` 记录了已逆向出的确定性签名部分(`e`、`s2`、`s3`),但脚本内部用于 `to` / `si` 的混淆逻辑尚未安全还原,因此未集成到生产路径。 +项目目录下的 `lx-music-source-v5.js` 是第三方混淆音源脚本(`lx` 平台)。由于内容不可审计,它**不会在服务器进程内直接执行**,而是通过 `src/apis/lx-source-ivm.js` 使用 isolated-vm 硬隔离加载:脚本在独立 V8 沙箱中发起网络请求,服务器只做日志审计与 HTTP 白名单管控。其请求签名中的 `to`/`si` 两项为脚本内部哈希,无法脱离脚本复算,直连签名方案已放弃,详见 `docs/lx-source-ivm.md`。 ## 已知限制 diff --git a/docs/API.md b/docs/API.md index a186fa2..e3a20fd 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,26 +1,26 @@ # MusicServer 聚合音乐 API -多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。 -支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)/ **mg**(咪咕)。 +多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。支持平台:**kw**(酷我)/ **kg**(酷狗)/ **tx**(QQ音乐)/ **wy**(网易云)/ **mg**(咪咕)。 > 本文档为静态速查版。交互式文档以服务器实际输出为准: > -> - **Swagger UI**:`http://localhost:3300/docs`(可直接调试) -> - **ReDoc**:`http://localhost:3300/redoc` -> - **OpenAPI 3.1**:`http://localhost:3300/openapi.json`(可生成各语言客户端) +> - **Swagger UI**:`http://localhost:16688/docs`(可直接调试) +> - **ReDoc**:`http://localhost:16688/redoc` +> - **OpenAPI 3.1**:`http://localhost:16688/openapi.json`(可生成各语言客户端) ## 约定 +- 默认端口 **16688**(可用 `PORT` 环境变量修改)。 +- 仅支持 `GET` 方法;服务器支持 CORS(`Access-Control-Allow-Origin: *`)。 - **成功**:`200` + `{"code": 0, "data": ...}` - **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}` - - `422` 参数校验失败(缺失、非法音质、页码越界等) + - `422` 参数校验失败(缺失、非法音质、页码越界等,FastAPI 风格) - `404` 接口不存在 / 平台不支持该能力 - - `405` 方法不允许(仅支持 GET) + - `405` 方法不允许(响应带 `Allow` 头) - `502` 上游平台请求失败 - `500` 服务器内部错误 -- 服务器支持 CORS(`*`),仅接受 `GET`。 -- 每个资源接口同时提供**路径参数风格**(规范)与**query 风格**(旧版兼容,已在文档中标记 deprecated)。 -- 平台歌曲 id 的形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`,mg 亦可用 `copyrightId`。 +- 每个资源接口同时提供**路径参数风格**(规范)与 **query 风格**(旧版兼容,文档中标记 deprecated)。 +- 平台歌曲 id 形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`(mg 内部自动换取 contentId)。 ## 接口速查 @@ -28,81 +28,60 @@ | 方法 | 路径 | 说明 | | --- | --- | --- | +| GET | `/health` | 健康检查(status / uptime / 平台数) | +| GET | `/sources` | 支持的平台及其音质、能力(search/url/lyric/pic/boards/hotSearch/songList) | | GET | `/` | 302 重定向到 `/docs` | -| GET | `/health` | 健康检查:`{status, uptime, platforms}` | -| GET | `/sources` | 平台列表:id、名称、音质、能力矩阵 | -| GET | `/docs` | Swagger UI 交互文档 | -| GET | `/redoc` | ReDoc 文档 | -| GET | `/openapi.json` | OpenAPI 3.1 规范 | +| GET | `/docs` | Swagger UI 文档页 | +| GET | `/redoc` | ReDoc 阅读版文档 | +| GET | `/openapi.json` | OpenAPI 3.1 规范文档 | ### 搜索 -| 方法 | 路径 | 关键参数 | -| --- | --- | --- | -| GET | `/search` | `name`(必填)、`singer`、`source`(省略=聚合)、`page=1`、`limit=20`(≤100) | -| GET | `/find` | `name`(必填)、`singer`、`albumName`、`interval`、`excludeSource` | - -`/search` 指定 `source` 时只查该平台(失败返回 502);省略时聚合全部平台,失败的平台静默跳过。 -`/find` 按歌手、歌名、专辑、时长综合打分,返回排序后的匹配列表。 - -### 歌曲资源(规范路由) - -| 方法 | 路径 | 关键参数 | -| --- | --- | --- | -| GET | `/url/:source/:id` | `quality`(128k/320k/flac/flac24bit,默认 128k)、`albumId` | -| GET | `/lyric/:source/:id` | `name`、`interval`(部分平台搜索歌词时需要) | -| GET | `/pic/:source/:id` | `albumId`(kg 需要) | - -旧版兼容:`/url?source=&id=&quality=`、`/lyric?source=&id=`、`/pic?source=&id=`。 - -### 榜单(规范路由) - | 方法 | 路径 | 说明 | | --- | --- | --- | -| GET | `/boards/:source` | 榜单列表 `{list:[{id,name,bangid}]}` | -| GET | `/boards/:source/:boardId` | 榜单详情,`page=1`,返回歌曲列表 | +| GET | `/search?name=稻香&singer=周杰伦&source=kw&page=1&limit=20` | 搜索歌曲;`source` 省略时聚合全部平台(排除 xm),失败的平台跳过 | +| GET | `/find?name=稻香&singer=周杰伦&albumName=魔杰座&interval=03:43&excludeSource=kw` | 跨平台精确匹配,按歌手/歌名/专辑/时长综合排序 | -旧版兼容:`/boards?source=`(列表)、`/board?source=&id=&page=`(详情)。 +### 歌曲资源 -### 歌单 & 热搜(规范路由) +| 方法 | 路径(规范) | 旧版 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 | `/song-list/:source` | `page=1`、`sortId=3`、`tagId=0`(平台相关) | -| GET | `/hot-search/:source` | 无 | +### 榜单 -旧版兼容:`/songList?source=&page=&sortId=&tagId=`、`/hotSearch?source=`。 +| 方法 | 路径(规范) | 旧版 query 别名(deprecated) | 说明 | +| --- | --- | --- | --- | +| GET | `/boards/:source` | `/boards?source=` | 榜单列表 | +| GET | `/boards/:source/:boardId` | `/board?source=&id=` | 榜单详情;可带 `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` 省略时用平台默认 | + +## 响应示例 ```jsonc -// GET /search?source=kw&name=稻香&limit=2 -{ "code": 0, "data": { "source": "kw", "list": [ /* SongInfo */ ], "page": 1, "limit": 2 } } +// GET /url/kw/228908?quality=320k +{ "code": 0, "data": { "type": "320k", "url": "https://..." } } -// SongInfo(节选) -{ - "songmid": "28423286", "hash": "...", "copyrightId": "...", "albumId": "...", - "name": "稻香", "singer": "周杰伦", "source": "kw", "interval": "03:43", - "albumName": "魔杰座", "img": "https://...", - "types": [{ "type": "320k", "size": "9.8M" }], - "_types": { "320k": { "size": "9.8M" } }, - "typeUrl": {} -} +// GET /lyric/kw/228908 +{ "code": 0, "data": { "lyric": "[00:00.00]...", "tlyric": "", "lxlyric": "" } } -// GET /url/kw/28423286?quality=320k -{ "code": 0, "data": { "type": "320k", "url": "http://..." } } +// 参数缺失 +// 422 +{ "code": 422, "msg": "querystring must have required property 'name'", "detail": [...] } -// GET /lyric/kw/28423286 -{ "code": 0, "data": { "lyric": "[ti:稻香]...", "tlyric": "", "lxlyric": "..." } } +// 平台不支持该能力 +// 404 +{ "code": 404, "msg": "不支持的平台: xx" } -// 422 示例 -{ "code": 422, "msg": "不支持的音质: 999k", "detail": { "enum": ["128k","320k","flac","flac24bit"] } } -``` - -## 客户端生成 - -`/openapi.json` 兼容 OpenAPI 3.1,可直接用工具生成客户端: - -```bash -npx openapi-typescript http://localhost:3300/openapi.json -o api-types.ts +// 方法不允许 +// 405 + Allow: GET +{ "code": 405, "msg": "方法 POST 不允许,请使用 GET" } ``` diff --git a/package.json b/package.json index 8c6e1a7..4600f96 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,11 @@ "#renderer/*": "./src/renderer/*.js" }, "dependencies": { + "@fastify/cors": "^11.3.0", + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^6.1.1", "crypto-js": "^4.2.0", + "fastify": "^5.12.1", "iconv-lite": "^0.6.3", "isolated-vm": "^7.0.1", "pako": "^2.1.0" diff --git a/src/http/api-error.js b/src/http/api-error.js index 85dae07..c40d2f7 100644 --- a/src/http/api-error.js +++ b/src/http/api-error.js @@ -15,9 +15,6 @@ export class ApiError extends Error { } } -/** 参数校验失败(FastAPI 风格使用 422) */ -export const invalidParam = (msg, detail) => new ApiError(422, msg, detail) - /** 资源不存在(未知路由 / 不支持的平台) */ export const notFound = (msg, detail) => new ApiError(404, msg, detail) diff --git a/src/http/router.js b/src/http/router.js deleted file mode 100644 index 99fe7cf..0000000 --- a/src/http/router.js +++ /dev/null @@ -1,53 +0,0 @@ -// 极简 HTTP 路由器 —— 路径参数(:name)、405 检测、携带路由元数据(供 OpenAPI 文档生成) - -export class Router { - #routes = [] - - get(pattern, meta, handler) { - this.#add('GET', pattern, meta, handler) - return this - } - - #add(method, pattern, meta, handler) { - const segments = pattern.replace(/^\/+|\/+$/g, '').split('/') - this.#routes.push({ method, pattern, segments, meta: meta || {}, handler }) - } - - /** - * 匹配路由 - * @returns {null | { handler: Function, meta: object, params: Record } | { allowed: string[] }} - * - null:无匹配路由(404) - * - { allowed }:路径存在但方法不符(405) - */ - match(pathname, method) { - const parts = pathname.replace(/^\/+|\/+$/g, '').split('/') - const allowed = new Set() - for (const route of this.#routes) { - const params = matchSegments(route.segments, parts) - if (!params) continue - if (route.method !== method) { - allowed.add(route.method) - continue - } - return { handler: route.handler, meta: route.meta, params } - } - if (allowed.size) return { allowed: [...allowed] } - return null - } - - /** 路由表(OpenAPI 文档生成用) */ - get routes() { - return this.#routes - } -} - -const matchSegments = (defined, actual) => { - if (defined.length !== actual.length) return null - const params = {} - for (let i = 0; i < defined.length; i++) { - const seg = defined[i] - if (seg.startsWith(':')) params[seg.slice(1)] = decodeURIComponent(actual[i]) - else if (seg !== actual[i]) return null - } - return params -} diff --git a/src/openapi/spec.js b/src/openapi/components.js similarity index 61% rename from src/openapi/spec.js rename to src/openapi/components.js index 4100868..937fd8f 100644 --- a/src/openapi/spec.js +++ b/src/openapi/components.js @@ -1,78 +1,24 @@ -// OpenAPI 3.1 文档生成 —— 从路由元数据构建 /openapi.json,供 Swagger UI(/docs)与 ReDoc(/redoc)使用 +// OpenAPI 领域模型 —— 注册为 Fastify 共享 schema(fastify.addSchema) +// 路由的 response schema 通过 `名称#` 形式的 $ref 引用; +// @fastify/swagger 生成文档时会自动映射到 components/schemas/名称 +// 注:additionalProperties: true 保证运行时序列化不剥离未声明字段 -const json = (schema) => ({ 'application/json': { schema } }) -const ref = (name) => ({ $ref: `#/components/schemas/${name}` }) +const ref = (name) => ({ $ref: `${name}#` }) -/** 200 响应体:统一信封 { code: 0, data } */ -const okResponse = (dataSchema, description) => ({ - description: description || '成功', - content: { - 'application/json': { - schema: { - type: 'object', - properties: { - code: { type: 'integer', description: '0 表示成功', example: 0 }, - data: dataSchema ? ref(dataSchema) : { description: '响应数据,结构由接口决定' }, - }, - required: ['code', 'data'], - }, - }, - }, -}) - -const errorResponse = (description) => ({ - description, - content: json(ref('ErrorEnvelope')), -}) - -const buildParameters = (meta) => { - const parameters = [] - for (const [name, def] of Object.entries(meta.pathParams || {})) { - parameters.push({ - name, - in: 'path', - required: true, - description: def.description, - schema: paramSchema(def), - example: def.example, - }) - } - for (const [name, def] of Object.entries(meta.queryParams || {})) { - parameters.push({ - name, - in: 'query', - required: Boolean(def.required), - description: def.description, - schema: paramSchema(def), - example: def.example, - }) - } - return parameters -} - -const paramSchema = (def) => { - const schema = { type: def.enum ? 'string' : (def.type || 'string') } - if (def.enum) schema.enum = def.enum - if (def.default !== undefined) schema.default = def.default - if (def.minimum !== undefined) schema.minimum = def.minimum - if (def.maximum !== undefined) schema.maximum = def.maximum - return schema -} - -/** 领域模型(components/schemas),与 musicSdk/types.ts 的运行时数据形状对应 */ export const COMPONENT_SCHEMAS = { ErrorEnvelope: { type: 'object', properties: { code: { type: 'integer', description: '与 HTTP 状态码一致', example: 422 }, msg: { type: 'string', description: '错误描述' }, - detail: { description: '可选的结构化错误详情' }, }, + additionalProperties: true, required: ['code', 'msg'], }, QualityInfo: { type: 'object', properties: { size: { type: 'string', example: '9.8M' } }, + additionalProperties: true, }, SongType: { type: 'object', @@ -80,6 +26,7 @@ export const COMPONENT_SCHEMAS = { type: { type: 'string', example: '320k', enum: ['128k', '320k', 'flac', 'flac24bit'] }, size: { type: 'string', example: '9.8M' }, }, + additionalProperties: true, }, SongInfo: { type: 'object', @@ -98,6 +45,7 @@ export const COMPONENT_SCHEMAS = { _types: { type: 'object', additionalProperties: ref('QualityInfo') }, typeUrl: { type: 'object' }, }, + additionalProperties: true, }, SearchResult: { type: 'object', @@ -107,12 +55,14 @@ export const COMPONENT_SCHEMAS = { page: { type: 'integer' }, limit: { type: 'integer' }, }, + additionalProperties: true, }, FindResult: { type: 'object', properties: { list: { type: 'array', items: ref('SongInfo'), description: '按匹配度排序的歌曲列表' }, }, + additionalProperties: true, }, MusicUrlResult: { type: 'object', @@ -120,6 +70,7 @@ export const COMPONENT_SCHEMAS = { type: { type: 'string', example: '320k' }, url: { type: 'string', description: '播放地址' }, }, + additionalProperties: true, }, LyricInfo: { type: 'object', @@ -129,11 +80,13 @@ export const COMPONENT_SCHEMAS = { rlyric: { type: 'string', description: '罗马音歌词(部分平台)' }, lxlyric: { type: 'string', description: '逐字歌词(部分平台)' }, }, + additionalProperties: true, required: ['lyric', 'tlyric'], }, PicResult: { type: 'object', properties: { url: { type: 'string', description: '封面地址' } }, + additionalProperties: true, }, BoardItem: { type: 'object', @@ -142,10 +95,12 @@ export const COMPONENT_SCHEMAS = { name: { type: 'string' }, bangid: { type: 'string', description: '平台原始榜单 id' }, }, + additionalProperties: true, }, BoardListResult: { type: 'object', properties: { list: { type: 'array', items: ref('BoardItem') } }, + additionalProperties: true, }, SongItem: { type: 'object', @@ -161,6 +116,7 @@ export const COMPONENT_SCHEMAS = { img: { type: 'string' }, types: { type: 'array', items: ref('SongType') }, }, + additionalProperties: true, }, BoardDetailResult: { type: 'object', @@ -171,6 +127,7 @@ export const COMPONENT_SCHEMAS = { limit: { type: 'integer' }, source: { type: 'string' }, }, + additionalProperties: true, }, HotSearchResult: { type: 'object', @@ -178,6 +135,7 @@ export const COMPONENT_SCHEMAS = { source: { type: 'string' }, list: { type: 'array', items: { type: 'string' } }, }, + additionalProperties: true, }, SongListSummary: { type: 'object', @@ -192,6 +150,7 @@ export const COMPONENT_SCHEMAS = { desc: { type: 'string' }, source: { type: 'string' }, }, + additionalProperties: true, }, SongListResult: { type: 'object', @@ -202,6 +161,7 @@ export const COMPONENT_SCHEMAS = { limit: { type: 'integer' }, source: { type: 'string' }, }, + additionalProperties: true, }, SourceInfo: { type: 'object', @@ -220,14 +180,17 @@ export const COMPONENT_SCHEMAS = { hotSearch: { type: 'boolean' }, songList: { type: 'boolean' }, }, + additionalProperties: true, }, }, + additionalProperties: true, }, SourcesResult: { type: 'object', properties: { platforms: { type: 'array', items: ref('SourceInfo') }, }, + additionalProperties: true, }, HealthResult: { type: 'object', @@ -236,46 +199,6 @@ export const COMPONENT_SCHEMAS = { uptime: { type: 'integer', description: '运行秒数' }, platforms: { type: 'integer', description: '已接入平台数' }, }, + additionalProperties: true, }, } - -/** - * 从路由元数据构建 OpenAPI 3.1 文档 - * @param {import('../http/router.js').Router} router - */ -export const buildOpenApi = (router, { title, description, version }) => { - const paths = {} - for (const route of router.routes) { - if (route.meta.hide) continue - const item = paths[route.pattern] || (paths[route.pattern] = {}) - const meta = route.meta - item[route.method.toLowerCase()] = { - summary: meta.summary || route.pattern, - ...(meta.description ? { description: meta.description } : {}), - tags: meta.tags || ['其他'], - ...(meta.deprecated ? { deprecated: true } : {}), - parameters: buildParameters(meta), - responses: meta.rawResponses || { - '200': okResponse(meta.dataSchema, meta.responseDescription), - '422': errorResponse('参数校验失败'), - ...(meta.possible404 ? { '404': errorResponse('不支持的平台') } : {}), - ...(meta.possible502 ? { '502': errorResponse('上游平台请求失败') } : {}), - }, - } - } - - return { - openapi: '3.1.0', - info: { title, description, version }, - servers: [{ url: '/', description: '当前服务器' }], - tags: [ - { name: '搜索', description: '歌曲搜索与跨平台匹配' }, - { name: '歌曲资源', description: '播放地址、歌词、封面' }, - { name: '榜单', description: '排行榜' }, - { name: '歌单', description: '歌单与热搜' }, - { name: '系统', description: '健康检查、平台信息、文档' }, - ], - paths, - components: { schemas: COMPONENT_SCHEMAS }, - } -} diff --git a/src/openapi/ui.js b/src/openapi/ui.js index 93bbe7e..594e271 100644 --- a/src/openapi/ui.js +++ b/src/openapi/ui.js @@ -1,44 +1,17 @@ -// 文档页面 —— Swagger UI(/docs)与 ReDoc(/redoc),静态 HTML + CDN 资源 +// ReDoc 文档页 —— Swagger UI 由 @fastify/swagger-ui 提供,此处仅保留 ReDoc 版本 -const SWAGGER_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui.css' -const SWAGGER_BUNDLE_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js' const REDOC_CDN = 'https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js' -const htmlShell = (title, body) => ` +export const redocHtml = ({ title, specUrl = '/openapi.json' }) => ` - ${title} - ${body.head} + ${title} (ReDoc) + - ${body.content} + + ` - -export const swaggerHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(title, { - head: ` - - `, - content: ` -
- - `, -}) - -export const redocHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(`${title} (ReDoc)`, { - head: ` - `, - content: ` - - `, -}) diff --git a/src/routes.js b/src/routes.js index 344a5a1..66b3736 100644 --- a/src/routes.js +++ b/src/routes.js @@ -1,9 +1,9 @@ -// 路由表 —— 接口定义 + OpenAPI 元数据 + 处理函数 -// 处理函数返回普通对象 → 200 { code: 0, data } -// 抛出 ApiError → 对应 HTTP 状态码;抛出其他错误 → 500 -// 返回 { __raw: true, status, headers, body } → 原样输出(文档页/重定向用) +// 业务路由 —— Fastify 插件形式 +// schema(querystring/params)负责参数校验并生成文档;schema.response 负责文档中的响应结构 +// 处理函数返回普通对象 → 由服务器的 preSerialization 钩子包成 { code: 0, data } +// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码 -import { invalidParam, notFound, upstream } from './http/api-error.js' +import { notFound, upstream } from './http/api-error.js' const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] @@ -14,16 +14,37 @@ const unwrap = (result) => ( : result ) -const required = (query, names) => { - const missing = names.filter(n => !query[n]) - if (missing.length) throw invalidParam(`缺少参数: ${missing.join(', ')}`, { missing }) -} +const envelope = (dataRef) => ({ + type: 'object', + properties: { + code: { type: 'integer', description: '0 表示成功', example: 0 }, + data: dataRef ? { $ref: `${dataRef}#` } : {}, + }, + required: ['code', 'data'], +}) -const toInt = (value, name, { min = 1, max = Infinity, def }) => { - const n = parseInt(value ?? def, 10) - if (Number.isNaN(n) || n < min || n > max) throw invalidParam(`参数 ${name} 需为 ${min}~${max} 的整数`) - return n -} +/** 组装路由的 schema.response:200 信封 + 通用错误码文档 */ +const responses = (dataRef, { notFoundDoc = true, upstreamDoc = true } = {}) => ({ + '200': envelope(dataRef), + '422': { $ref: 'ErrorEnvelope#' }, + ...(notFoundDoc ? { '404': { $ref: 'ErrorEnvelope#' } } : {}), + ...(upstreamDoc ? { '502': { $ref: 'ErrorEnvelope#' } } : {}), +}) + +// ---- 常用 JSON Schema 片段 ---- + +const pSource = (capability) => ({ + type: 'string', + description: `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`, + example: 'kw', +}) +const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' } +const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' } +const pQuality = { type: 'string', enum: QUALITIES, default: '128k', description: '音质' } +const pId = { type: 'string', description: '平台歌曲 id', example: '28423286' } + +const qs = (properties, required = []) => ({ type: 'object', properties, required }) +const params = (properties, required) => qs(properties, required ?? Object.keys(properties)) const getSource = (musicSdk, name, capability) => { const m = musicSdk[name] @@ -31,55 +52,40 @@ const getSource = (musicSdk, name, capability) => { return m } -// 常用查询参数元数据(多接口复用) -const pSource = (capability, description) => ({ - description: description || `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`, - example: 'kw', -}) -const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' } -const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' } -const pQuality = { - enum: QUALITIES, - default: '128k', - description: '音质', -} - /** * 注册全部业务路由 - * @param {import('./http/router.js').Router} router - * @param {object} musicSdk 聚合 SDK + * @param {import('fastify').FastifyInstance} fastify */ -export const registerRoutes = (router, musicSdk) => { +export const musicRoutes = async(fastify) => { + const musicSdk = fastify.musicSdk + // ---------- 搜索 ---------- - router.get('/search', { - summary: '搜索歌曲', - description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。', - tags: ['搜索'], - queryParams: { - name: { required: true, description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' }, - singer: { description: '歌手名', example: '周杰伦' }, - source: pSource('search'), - page: pPage, - limit: pLimit, + fastify.get('/search', { + schema: { + summary: '搜索歌曲', + description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。', + tags: ['搜索'], + querystring: qs({ + name: { type: 'string', description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' }, + singer: { type: 'string', description: '歌手名', example: '周杰伦' }, + source: pSource('search'), + page: pPage, + limit: pLimit, + }, ['name']), + response: responses('SearchResult', { notFoundDoc: false }), }, - dataSchema: 'SearchResult', - possible502: true, - }, async({ query }) => { - required(query, ['name']) - const name = query.name - const singer = query.singer - const page = toInt(query.page, 'page', { def: 1 }) - const limit = toInt(query.limit, 'limit', { def: 20, max: 100 }) + }, async(request) => { + const { name, singer, source, page, limit } = request.query const doSearch = (id) => musicSdk[id].musicSearch .search(`${name} ${singer || ''}`.trim(), page, limit) .then(r => r.list) - if (query.source) { - getSource(musicSdk, query.source, 'musicSearch') + if (source) { + getSource(musicSdk, source, 'musicSearch') try { - return { source: query.source, list: await doSearch(query.source), page, limit } + return { source, list: await doSearch(source), page, limit } } catch (err) { throw upstream(err.message) } @@ -93,40 +99,38 @@ export const registerRoutes = (router, musicSdk) => { return { source: 'all', list: lists.flat(), page, limit } }) - router.get('/find', { - summary: '跨平台精确匹配歌曲', - description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。', - tags: ['搜索'], - queryParams: { - name: { required: true, description: '歌名', example: '稻香' }, - singer: { description: '歌手名', example: '周杰伦' }, - albumName: { description: '专辑名', example: '魔杰座' }, - interval: { description: '时长(mm:ss),用于时长校验', example: '03:43' }, - excludeSource: { description: '聚合结果中跳过该平台', example: 'kw' }, + fastify.get('/find', { + schema: { + summary: '跨平台精确匹配歌曲', + description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。', + tags: ['搜索'], + querystring: qs({ + name: { type: 'string', description: '歌名', example: '稻香' }, + singer: { type: 'string', description: '歌手名', example: '周杰伦' }, + albumName: { type: 'string', description: '专辑名', example: '魔杰座' }, + interval: { type: 'string', description: '时长(mm:ss),用于时长校验', example: '03:43' }, + excludeSource: { type: 'string', description: '聚合结果中跳过该平台', example: 'kw' }, + }, ['name']), + response: responses('FindResult', { notFoundDoc: false }), }, - dataSchema: 'FindResult', - possible502: true, - }, async({ query }) => { - required(query, ['name']) + }, async(request) => { + const { name, singer, albumName, interval, excludeSource } = request.query try { return { - list: await musicSdk.findMusic({ - name: query.name, - singer: query.singer, - albumName: query.albumName, - interval: query.interval, - source: query.excludeSource, - }), + list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }), } } catch (err) { throw upstream(err.message) } }) - // ---------- 歌曲资源(路径参数风格,含旧 query 风格别名) ---------- + // ---------- 歌曲资源(路径参数风格 + 旧 query 风格别名) ---------- - const urlHandler = ({ source, id, quality, albumId }) => { - if (!QUALITIES.includes(quality)) throw invalidParam(`不支持的音质: ${quality}`, { enum: QUALITIES }) + 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 m = getSource(musicSdk, source, 'getMusicUrl') const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} } let result @@ -139,47 +143,44 @@ export const registerRoutes = (router, musicSdk) => { if (!res?.url) throw upstream('获取播放地址失败') return { type: quality, url: res.url } }).catch(err => { - throw err instanceof Error && err.status ? err : upstream(err.message) + throw err.status ? err : upstream(err.message) }) } - router.get('/url/:source/:id', { - summary: '获取播放地址', - description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。', - tags: ['歌曲资源'], - pathParams: { - source: pSource('url'), - id: { description: '平台歌曲 id', example: '28423286' }, + fastify.get('/url/:source/:id', { + schema: { + summary: '获取播放地址', + description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。', + tags: ['歌曲资源'], + params: params({ + source: pSource('url'), + id: pId, + }), + querystring: qs({ quality: pQuality, albumId: { type: 'string', description: '专辑 id' } }), + response: responses('MusicUrlResult'), }, - queryParams: { - quality: pQuality, - albumId: { description: '专辑 id(部分平台获取封面时需要)' }, - }, - dataSchema: 'MusicUrlResult', - possible404: true, - possible502: true, - }, ({ params, query }) => urlHandler({ - source: params.source, id: params.id, quality: query.quality || '128k', albumId: query.albumId, - })) + }, urlHandler) - router.get('/url', { - summary: '获取播放地址(旧版 query 风格)', - tags: ['歌曲资源'], - deprecated: true, - queryParams: { - source: { ...pSource('url'), required: true }, - id: { required: true, description: '平台歌曲 id' }, - quality: pQuality, - albumId: { description: '专辑 id' }, + 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'), }, - dataSchema: 'MusicUrlResult', - possible404: true, - possible502: true, - }, ({ query }) => urlHandler({ - source: query.source, id: query.id, quality: query.quality || '128k', albumId: query.albumId, - })) + }, urlHandler) - const lyricHandler = ({ source, id, name, interval }) => { + 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 m = getSource(musicSdk, source, 'getLyric') let result try { @@ -188,46 +189,43 @@ export const registerRoutes = (router, musicSdk) => { throw upstream(err.message) } return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => { - throw err instanceof Error && err.status ? err : upstream(err.message) + throw err.status ? err : upstream(err.message) }) } - router.get('/lyric/:source/:id', { - summary: '获取歌词', - tags: ['歌曲资源'], - pathParams: { - source: pSource('lyric'), - id: { description: '平台歌曲 id', example: '28423286' }, + fastify.get('/lyric/:source/:id', { + schema: { + summary: '获取歌词', + tags: ['歌曲资源'], + params: params({ source: pSource('lyric'), id: pId }), + querystring: qs({ + name: { type: 'string', description: '歌名(部分平台搜索歌词时需要)' }, + interval: { type: 'string', description: '时长(mm:ss),部分平台搜索歌词时需要' }, + }), + response: responses('LyricInfo'), }, - queryParams: { - name: { description: '歌名(部分平台搜索歌词时需要)' }, - interval: { description: '时长(mm:ss),部分平台搜索歌词时需要' }, - }, - dataSchema: 'LyricInfo', - possible404: true, - possible502: true, - }, ({ params, query }) => lyricHandler({ - source: params.source, id: params.id, name: query.name, interval: query.interval, - })) + }, lyricHandler) - router.get('/lyric', { - summary: '获取歌词(旧版 query 风格)', - tags: ['歌曲资源'], - deprecated: true, - queryParams: { - source: { ...pSource('lyric'), required: true }, - id: { required: true, description: '平台歌曲 id' }, - name: { description: '歌名' }, - interval: { description: '时长(mm:ss)' }, + 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'), }, - dataSchema: 'LyricInfo', - possible404: true, - possible502: true, - }, ({ query }) => lyricHandler({ - source: query.source, id: query.id, name: query.name, interval: query.interval, - })) + }, lyricHandler) - const picHandler = ({ source, id, albumId }) => { + 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 m = getSource(musicSdk, source, 'getPic') let result try { @@ -236,205 +234,189 @@ export const registerRoutes = (router, musicSdk) => { throw upstream(err.message) } return result.then(url => ({ url })).catch(err => { - throw err instanceof Error && err.status ? err : upstream(err.message) + throw err.status ? err : upstream(err.message) }) } - router.get('/pic/:source/:id', { - summary: '获取封面', - tags: ['歌曲资源'], - pathParams: { - source: pSource('pic'), - id: { description: '平台歌曲 id', example: '28423286' }, + fastify.get('/pic/:source/:id', { + schema: { + summary: '获取封面', + tags: ['歌曲资源'], + params: params({ source: pSource('pic'), id: pId }), + querystring: qs({ albumId: { type: 'string', description: '专辑 id(kg 平台需要)' } }), + response: responses('PicResult'), }, - queryParams: { - albumId: { description: '专辑 id(kg 平台需要)' }, - }, - dataSchema: 'PicResult', - possible404: true, - possible502: true, - }, ({ params, query }) => picHandler({ - source: params.source, id: params.id, albumId: query.albumId, - })) + }, picHandler) - router.get('/pic', { - summary: '获取封面(旧版 query 风格)', - tags: ['歌曲资源'], - deprecated: true, - queryParams: { - source: { ...pSource('pic'), required: true }, - id: { required: true, description: '平台歌曲 id' }, - albumId: { description: '专辑 id' }, + 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'), }, - dataSchema: 'PicResult', - possible404: true, - possible502: true, - }, ({ query }) => picHandler({ - source: query.source, id: query.id, albumId: query.albumId, - })) + }, picHandler) // ---------- 榜单 ---------- - const boardsHandler = (source) => { - const m = getSource(musicSdk, source, 'leaderboard') + fastify.get('/boards/:source', { + schema: { + summary: '获取榜单列表', + tags: ['榜单'], + params: params({ source: pSource('boards') }), + response: responses('BoardListResult'), + }, + }, async(request) => { + const m = getSource(musicSdk, request.params.source, 'leaderboard') return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) }) - } - - router.get('/boards/:source', { - summary: '获取榜单列表', - tags: ['榜单'], - pathParams: { source: pSource('boards') }, - dataSchema: 'BoardListResult', - possible404: true, - possible502: true, - }, ({ params }) => boardsHandler(params.source)) - - router.get('/boards', { - summary: '获取榜单列表(旧版 query 风格)', - tags: ['榜单'], - deprecated: true, - queryParams: { source: { ...pSource('boards'), required: true } }, - dataSchema: 'BoardListResult', - possible404: true, - possible502: true, - }, ({ query }) => { - required(query, ['source']) - return boardsHandler(query.source) }) - const boardHandler = ({ source, id, page }) => { + 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') - return m.leaderboard.getList(id, page).catch(err => { throw upstream(err.message) }) + // 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化 + const boardId = String(id).replace(new RegExp(`^${source}__`), '') + return m.leaderboard.getList(boardId, page).catch(err => { throw upstream(err.message) }) } - router.get('/boards/:source/:boardId', { - summary: '获取榜单详情', - tags: ['榜单'], - pathParams: { - source: pSource('boards'), - boardId: { description: '榜单 id(来自榜单列表接口)', example: 'tx__26' }, + fastify.get('/boards/:source/:boardId', { + schema: { + summary: '获取榜单详情', + tags: ['榜单'], + params: params({ + source: pSource('boards'), + boardId: { type: 'string', description: '榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)', example: 'kw__93' }, + }), + querystring: qs({ page: pPage }), + response: responses('BoardDetailResult'), }, - queryParams: { page: pPage }, - dataSchema: 'BoardDetailResult', - possible404: true, - possible502: true, - }, ({ params, query }) => boardHandler({ - source: params.source, id: params.boardId, page: toInt(query.page, 'page', { def: 1 }), - })) + }, async(request) => boardHandler( + request.params.source, request.params.boardId, request.query.page, + )) - router.get('/board', { - summary: '获取榜单详情(旧版 query 风格)', - tags: ['榜单'], - deprecated: true, - queryParams: { - source: { ...pSource('boards'), required: true }, - id: { required: true, description: '榜单 id' }, - page: pPage, + 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'), }, - dataSchema: 'BoardDetailResult', - possible404: true, - possible502: true, - }, ({ query }) => { - required(query, ['source', 'id']) - return boardHandler({ - source: query.source, id: query.id, page: toInt(query.page, 'page', { def: 1 }), - }) - }) + }, async(request) => boardHandler( + request.query.source, request.query.id, request.query.page, + )) // ---------- 歌单 & 热搜 ---------- - const hotSearchHandler = (source) => { - const m = getSource(musicSdk, source, 'hotSearch') + fastify.get('/hot-search/:source', { + schema: { + summary: '获取热搜词', + tags: ['歌单'], + params: params({ source: pSource('hotSearch') }), + response: responses('HotSearchResult'), + }, + }, async(request) => { + const m = getSource(musicSdk, request.params.source, 'hotSearch') return m.hotSearch.getList().catch(err => { throw upstream(err.message) }) - } - - router.get('/hot-search/:source', { - summary: '获取热搜词', - tags: ['歌单'], - pathParams: { source: pSource('hotSearch') }, - dataSchema: 'HotSearchResult', - possible404: true, - possible502: true, - }, ({ params }) => hotSearchHandler(params.source)) - - router.get('/hotSearch', { - summary: '获取热搜词(旧版 query 风格)', - tags: ['歌单'], - deprecated: true, - queryParams: { source: { ...pSource('hotSearch'), required: true } }, - dataSchema: 'HotSearchResult', - possible404: true, - possible502: true, - }, ({ query }) => { - required(query, ['source']) - return hotSearchHandler(query.source) }) - const songListHandler = ({ source, page, sortId, tagId }) => { + 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') - return m.songList.getList(sortId ?? 3, tagId ?? 0, page).catch(err => { throw upstream(err.message) }) + // sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序 + return m.songList.getList(sortId ?? m.songList.sortList?.[0]?.id, tagId, page).catch(err => { throw upstream(err.message) }) } - router.get('/song-list/:source', { - summary: '获取歌单列表', - tags: ['歌单'], - pathParams: { source: pSource('songList') }, - queryParams: { - page: pPage, - sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' }, - tagId: { type: 'integer', default: 0, description: '标签 id,0 为全部' }, - }, - dataSchema: 'SongListResult', - possible404: true, - possible502: true, - }, ({ params, query }) => songListHandler({ - source: params.source, - page: toInt(query.page, 'page', { def: 1 }), - sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }), - tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }), - })) - - router.get('/songList', { - summary: '获取歌单列表(旧版 query 风格)', - tags: ['歌单'], - deprecated: true, - queryParams: { - source: { ...pSource('songList'), required: true }, - page: pPage, - sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' }, - tagId: { type: 'integer', default: 0, description: '标签 id' }, - }, - dataSchema: 'SongListResult', - possible404: true, - possible502: true, - }, ({ query }) => { - required(query, ['source']) - return songListHandler({ - source: query.source, - page: toInt(query.page, 'page', { def: 1 }), - sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }), - tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }), + fastify.get('/song-list/:source', { + schema: { + 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']), + response: responses('SongListResult'), + }, + }, async(request) => { + const { source, page, sortId, tagId } = request.query + return songListHandler(source, page, sortId, tagId) }) // ---------- 系统 ---------- - router.get('/health', { - summary: '健康检查', - tags: ['系统'], - dataSchema: 'HealthResult', - }, () => ({ + fastify.get('/health', { + schema: { + summary: '健康检查', + tags: ['系统'], + response: responses('HealthResult', { notFoundDoc: false, upstreamDoc: false }), + }, + }, async() => ({ status: 'ok', uptime: Math.round(process.uptime()), platforms: musicSdk.sources.length, })) - router.get('/sources', { - summary: '获取支持的平台', - description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。', - tags: ['系统'], - dataSchema: 'SourcesResult', - }, () => ({ + fastify.get('/sources', { + schema: { + summary: '获取支持的平台', + description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。', + tags: ['系统'], + response: responses('SourcesResult', { notFoundDoc: false, upstreamDoc: false }), + }, + }, async() => ({ platforms: musicSdk.sources.map(({ id, name }) => { const m = musicSdk[id] || {} const qualities = [...new Set( diff --git a/src/server.js b/src/server.js index 196192c..e130d39 100644 --- a/src/server.js +++ b/src/server.js @@ -1,5 +1,5 @@ -// HTTP 服务器入口 —— 多平台音乐聚合 API -// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档 +// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API +// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动) // // 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容) // GET /search (同左) @@ -16,168 +16,183 @@ // 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 // 响应信封:成功 { code: 0, data };失败 { code: , msg, detail? } -import http from 'node:http' +import Fastify from 'fastify' +import cors from '@fastify/cors' +import swagger from '@fastify/swagger' +import swaggerUi from '@fastify/swagger-ui' import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' -import { URL } from 'node:url' import musicSdk from '../musicSdk/index.js' -import { Router } from './http/router.js' import { ApiError } from './http/api-error.js' -import { registerRoutes } from './routes.js' -import { buildOpenApi } from './openapi/spec.js' -import { swaggerHtml, redocHtml } from './openapi/ui.js' +import { musicRoutes } from './routes.js' +import { COMPONENT_SCHEMAS } from './openapi/components.js' +import { redocHtml } from './openapi/ui.js' -const PORT = process.env.PORT || 3300 +// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间 +const PORT = process.env.PORT || 16688 const API_TITLE = 'MusicServer 聚合音乐 API' const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。' const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')) -// ---- 路由装配 ---- +const fastify = Fastify({ + logger: false, + // schema 中的 example 等文档关键字仅供 OpenAPI 使用,关闭 Ajv 严格模式以免校验构建报错 + ajv: { customOptions: { strict: false } }, +}) +fastify.decorate('musicSdk', musicSdk) -const router = new Router() -registerRoutes(router, musicSdk) +// ---- 共享 schema(领域模型:校验引用与 OpenAPI components 同源) ---- +for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) { + fastify.addSchema({ ...schema, $id: name }) +} -let specCache = null -const openApiSpec = () => { - if (!specCache) specCache = buildOpenApi(router, { - title: API_TITLE, - description: API_DESCRIPTION, - version: pkg.version, +// ---- 全局中间行为 ---- + +// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定 +const routePatterns = [] +fastify.addHook('onRoute', (routeOptions) => { + if (!routeOptions.url || !routeOptions.method) return + // :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义 + const source = routeOptions.url + .replace(/:[^/]+/g, '\u0000') + .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + .replace(/\u0000/g, '[^/]+') + .replace(/\\\*/g, '.*') + routePatterns.push({ method: routeOptions.method, regex: new RegExp(`^${source}$`) }) +}) + +fastify.register(cors, { origin: '*' }) + +fastify.addHook('onRequest', async(_request, reply) => { + reply.header('Cache-Control', 'no-store') +}) + +fastify.addHook('onResponse', async(request, reply) => { + console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} → ${reply.statusCode} (${Math.round(reply.elapsedTime)}ms)`) +}) + +// 成功响应统一包成 { code: 0, data };文档页/raw 路由与非 200 响应不包 +fastify.addHook('preSerialization', async(request, reply, payload) => { + if (reply.statusCode !== 200) return payload + if (request.routeOptions.config?.raw) return payload + if (payload === null || typeof payload === 'object') return { code: 0, data: payload } + return payload +}) + +// ---- 错误处理 ---- + +fastify.setErrorHandler((err, request, reply) => { + if (err instanceof ApiError) { + const payload = { code: err.status, msg: err.message } + if (err.detail !== undefined) payload.detail = err.detail + return reply.code(err.status).send(payload) + } + // JSON Schema 校验失败(FastAPI 同款 422 语义) + if (err.validation) { + return reply.code(422).send({ code: 422, msg: err.message, detail: err.validation }) + } + console.error(`[${new Date().toISOString()}] ${request.method} ${request.url} 内部错误:`, err) + return reply.code(500).send({ code: 500, msg: '服务器内部错误' }) +}) + +fastify.setNotFoundHandler((request, reply) => { + // 路径存在但方法不允许 → 405 + Allow 头(Fastify 默认给 404,这里补齐标准语义) + // 用模式匹配而非 hasRoute:hasRoute 需要精确 URL,匹配不到 /url/:source/:id 这类参数路由 + const pathname = new URL(request.url, 'http://localhost').pathname + const allowed = [...new Set( + routePatterns + .filter(r => r.regex.test(pathname) && r.method !== request.method) + // OPTIONS/HEAD 是辅助方法(CORS 预检由 @fastify/cors 的通配路由承接),不应计入 405 判定 + .filter(r => r.method !== 'OPTIONS' && r.method !== 'HEAD') + .map(r => r.method), + )] + if (allowed.length) { + reply.code(405).header('Allow', allowed.join(', ')) + return reply.send({ code: 405, msg: `方法 ${request.method} 不允许,请使用 ${allowed.join(', ')}` }) + } + reply.code(404).send({ code: 404, msg: `接口不存在: ${request.url}` }) +}) + +// ---- OpenAPI 文档 ---- + +fastify.register(swagger, { + // 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读) + refResolver: { + buildLocalReference: (json, _baseUri, _fragment, i) => json.$id || `def-${i}`, + }, + openapi: { + openapi: '3.1.0', + info: { + title: API_TITLE, + description: API_DESCRIPTION, + version: pkg.version, + }, + servers: [{ url: '/', description: '当前服务器' }], + tags: [ + { name: '搜索', description: '歌曲搜索与跨平台匹配' }, + { name: '歌曲资源', description: '播放地址、歌词、封面' }, + { name: '榜单', description: '排行榜' }, + { name: '歌单', description: '歌单与热搜' }, + { name: '系统', description: '健康检查、平台信息、文档' }, + ], + }, +}) + +fastify.register(swaggerUi, { + routePrefix: '/docs', + url: '/openapi.json', +}) + +fastify.get('/openapi.json', { + config: { raw: true }, + schema: { + summary: 'OpenAPI 3.1 规范文档', + description: '机器可读的接口描述,可用于生成各语言客户端。', + tags: ['系统'], + }, +}, async() => fastify.swagger()) + +fastify.get('/redoc', { + schema: { + summary: 'ReDoc 阅读版文档', + tags: ['系统'], + }, +}, async(_request, reply) => reply.type('text/html; charset=utf-8').send(redocHtml({ title: API_TITLE }))) + +fastify.get('/', { + schema: { + summary: 'API 文档入口', + description: '302 重定向到 Swagger UI 文档页。', + tags: ['系统'], + }, +}, async(_request, reply) => reply.redirect('/docs')) + +// ---- 业务路由 ---- + +fastify.register(musicRoutes) + +export const startServer = async(port = PORT) => { + await fastify.listen({ port, host: '0.0.0.0' }) + console.log(`音乐服务器已启动: http://localhost:${port}`) + console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`) + console.log(`ReDoc 文档: http://localhost:${port}/redoc`) + console.log(`OpenAPI: http://localhost:${port}/openapi.json`) + return fastify +} + +const shutdown = (signal) => { + console.log(`\n收到 ${signal},正在关闭服务器...`) + fastify.close().then(() => { + console.log('服务器已关闭') + process.exit(0) }) - return specCache -} - -router.get('/', { - summary: 'API 文档入口', - description: '302 重定向到 Swagger UI 文档页。', - tags: ['系统'], - rawResponses: { '302': { description: '重定向到 /docs' } }, -}, () => ({ __raw: true, status: 302, headers: { Location: '/docs' }, body: '' })) - -router.get('/docs', { - summary: 'Swagger UI 交互文档', - description: '基于 OpenAPI 规范的交互式文档,可直接在页面上调试接口。', - tags: ['系统'], - rawResponses: { '200': { description: 'Swagger UI 页面' } }, -}, () => ({ - __raw: true, - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - body: swaggerHtml({ title: API_TITLE }), -})) - -router.get('/redoc', { - summary: 'ReDoc 阅读版文档', - tags: ['系统'], - rawResponses: { '200': { description: 'ReDoc 页面' } }, -}, () => ({ - __raw: true, - status: 200, - headers: { 'Content-Type': 'text/html; charset=utf-8' }, - body: redocHtml({ title: API_TITLE }), -})) - -router.get('/openapi.json', { - summary: 'OpenAPI 3.1 规范文档', - description: '机器可读的接口描述,可用于生成各语言客户端。', - tags: ['系统'], - rawResponses: { '200': { description: 'OpenAPI 规范(JSON)' } }, -}, () => ({ - __raw: true, - status: 200, - headers: { 'Content-Type': 'application/json; charset=utf-8' }, - body: JSON.stringify(openApiSpec(), null, 2), -})) - -// ---- HTTP 处理 ---- - -const sendJson = (res, status, payload) => { - if (res.writableEnded) return - res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) - res.end(JSON.stringify(payload)) -} - -const logRequest = (req, pathname, search, status, ms) => { - console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}${search} → ${status} (${ms}ms)`) -} - -const handler = async(req, res) => { - const started = Date.now() - res.setHeader('Access-Control-Allow-Origin', '*') - res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') - res.setHeader('Access-Control-Allow-Headers', 'Content-Type') - res.setHeader('Cache-Control', 'no-store') - - if (req.method === 'OPTIONS') { - res.writeHead(204) - res.end() - logRequest(req, req.url, '', 204, Date.now() - started) - return - } - - const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`) - const query = Object.fromEntries(url.searchParams.entries()) - let status = 500 - - try { - const matched = router.match(url.pathname, req.method) - if (!matched) throw new ApiError(404, `接口不存在: ${url.pathname}`) - if (matched.allowed) { - res.setHeader('Allow', matched.allowed.join(', ')) - throw new ApiError(405, `方法 ${req.method} 不允许,请使用 ${matched.allowed.join(', ')}`) - } - - const out = await matched.handler({ req, res, params: matched.params, query }) - if (out && out.__raw) { - status = out.status || 200 - res.writeHead(status, out.headers) - res.end(out.body) - } else { - status = 200 - sendJson(res, status, { code: 0, data: out }) - } - } catch (err) { - if (err instanceof ApiError) { - status = err.status - const payload = { code: err.status, msg: err.message } - if (err.detail !== undefined) payload.detail = err.detail - sendJson(res, status, payload) - } else { - status = 500 - console.error(`[${new Date().toISOString()}] ${req.method} ${url.pathname} 内部错误:`, err) - sendJson(res, status, { code: 500, msg: '服务器内部错误' }) - } - } - - logRequest(req, url.pathname, url.search, status, Date.now() - started) -} - -export const startServer = (port = PORT) => { - const server = http.createServer(handler) - server.listen(port, () => { - console.log(`音乐服务器已启动: http://localhost:${port}`) - console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`) - console.log(`ReDoc 文档: http://localhost:${port}/redoc`) - console.log(`OpenAPI: http://localhost:${port}/openapi.json`) - }) - - const shutdown = (signal) => { - console.log(`\n收到 ${signal},正在关闭服务器...`) - server.close(() => { - console.log('服务器已关闭') - process.exit(0) - }) - server.closeAllConnections?.() - setTimeout(() => process.exit(0), 3000).unref() - } - process.on('SIGTERM', () => shutdown('SIGTERM')) - process.on('SIGINT', () => shutdown('SIGINT')) - - return server + setTimeout(() => process.exit(0), 3000).unref() } +process.on('SIGTERM', () => shutdown('SIGTERM')) +process.on('SIGINT', () => shutdown('SIGINT')) // 直接运行时启动 if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) { diff --git a/src/smoke-test.js b/src/smoke-test.js index 4d84177..51a4421 100644 --- a/src/smoke-test.js +++ b/src/smoke-test.js @@ -1,13 +1,15 @@ // 冒烟测试 —— 验证服务器各接口与各平台功能 // 运行:node src/smoke-test.js // -// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜、 -// 标准 HTTP 语义(404/405/422/302)、文档(/docs /openapi.json /sources) +// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、 +// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、文档页 import http from 'node:http' import { spawn } from 'node:child_process' -const BASE = process.env.BASE_URL || 'http://localhost:3300' +// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖 +const PORT = process.env.PORT || 16688 +const BASE = process.env.BASE_URL || `http://localhost:${PORT}` const results = [] const get = (path) => new Promise((resolve, reject) => { @@ -69,6 +71,7 @@ const isServerRunning = () => new Promise(resolve => { const startLocalServer = () => new Promise((resolve, reject) => { const proc = spawn(process.execPath, ['src/server.js'], { cwd: process.cwd(), + env: { ...process.env, PORT: String(PORT) }, stdio: ['ignore', 'pipe', 'pipe'], }) let stdout = '' @@ -208,6 +211,42 @@ await test('榜单 路径风格 /boards/:source', async() => { 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') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表') + return `${r.data.list.length} 首` +}) + +await test('跨平台匹配 /find', async() => { + const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`) + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表') + return `${r.data.list[0].name} / ${r.data.list[0].singer}` +}) + +await test('歌单 路径风格 /song-list/:source', async() => { + const r = await get('/song-list/kg?page=1') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表') + return `${r.data.list.length} 个歌单` +}) + +await test('歌单 默认排序 /song-list/kw', async() => { + const r = await get('/song-list/kw?page=1') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)') + return `${r.data.list.length} 个歌单` +}) + await test('参数校验 422(缺少 name)', async() => { const r = await getRaw('/search') if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) @@ -228,13 +267,20 @@ await test('404 未知接口', async() => { return JSON.parse(r.body).msg }) -await test('405 方法不允许', async() => { +await test('405 方法不允许(静态路由)', async() => { const r = await postRaw('/search') if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`) if (!r.headers.allow) throw new Error('缺少 Allow 头') return `Allow: ${r.headers.allow}` }) +await test('405 方法不允许(参数路由)', async() => { + const r = await postRaw('/url/kw/28423286') + if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`) + if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`) + return `Allow: ${r.headers.allow}` +}) + await test('根路径 302 → /docs', async() => { const r = await getRaw('/') if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`) @@ -255,7 +301,7 @@ await test('OpenAPI 规范 /openapi.json', async() => { const spec = JSON.parse(r.body) if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`) if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义') - if (!spec.paths?.['/url/:source/:id']) throw new Error('缺少路径参数路由定义') + if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义') return `${Object.keys(spec.paths).length} 个路径` }) diff --git a/tmp/check-openapi.mjs b/tmp/check-openapi.mjs deleted file mode 100644 index f7e2f53..0000000 --- a/tmp/check-openapi.mjs +++ /dev/null @@ -1,25 +0,0 @@ -// 临时验证脚本 —— 检查 OpenAPI 规范与文档页产出 -import { startServer } from '../src/server.js' - -const server = startServer(3459) -await new Promise(r => setTimeout(r, 500)) - -const spec = await fetch('http://localhost:3459/openapi.json').then(r => r.json()) -const urlRoute = spec.paths['/url/:source/:id'].get -console.log('openapi:', spec.openapi, '| paths:', Object.keys(spec.paths).length) -console.log('url params:', JSON.stringify(urlRoute.parameters.map(p => `${p.name}:${p.in}${p.required ? '(required)' : ''}`))) -console.log('url responses:', Object.keys(urlRoute.responses).join(', ')) -console.log('deprecated routes:', Object.entries(spec.paths).filter(([, v]) => v.get.deprecated).map(([k]) => k).join(', ')) -console.log('schemas:', Object.keys(spec.components.schemas).length) - -const html = await fetch('http://localhost:3459/docs').then(r => r.text()) -console.log('docs page ok:', html.includes('swagger-ui-bundle') && html.includes('/openapi.json')) -const redoc = await fetch('http://localhost:3459/redoc').then(r => r.text()) -console.log('redoc page ok:', redoc.includes('redoc')) - -const sources = await fetch('http://localhost:3459/sources').then(r => r.json()) -console.log('sources:', JSON.stringify(sources.data.platforms.map(p => `${p.id}:${p.qualities.join('/')}`))) -console.log('kw caps:', JSON.stringify(sources.data.platforms.find(p => p.id === 'kw').capabilities)) - -server.close() -process.exit(0) diff --git a/tmp/reproduce-lx-source-v5.mjs b/tmp/reproduce-lx-source-v5.mjs deleted file mode 100644 index eca777b..0000000 --- a/tmp/reproduce-lx-source-v5.mjs +++ /dev/null @@ -1,139 +0,0 @@ -// lx-music-source-v5 复现/追踪脚本(与生产加载器 src/apis/lx-source-ivm.js 相互独立) -// 1) 将去混淆脚本末尾的字符串缓存 [] 替换为 LOGCACHE(Proxy),按序记录 JSVMP 解码的全部字符串 -// 2) 模拟 lx-music-desktop preload.js 的 globalThis.lx 环境,观察脚本的注册与取流行为 -// 运行:node tmp/reproduce-lx-source-v5.mjs -import crypto from 'node:crypto' -import fs from 'node:fs' -import vm from 'node:vm' -import zlib from 'node:zlib' -import { promisify } from 'node:util' -import { fileURLToPath } from 'node:url' - -const inflate = promisify(zlib.inflate) -const deflate = promisify(zlib.deflate) -const md5 = (s) => crypto.createHash('md5').update(s).digest('hex') - -const deobPath = fileURLToPath(new URL('../lx-music-source-v5-deobfuscated.js', import.meta.url)) -const obfPath = fileURLToPath(new URL('../lx-music-source-v5.js', import.meta.url)) - -let code = fs.readFileSync(deobPath, 'utf8') - -// 字符串缓存:文件末尾 IIFE 最后一个参数 [] -const cacheLog = [] -const LOGCACHE = new Proxy([], { - set(t, prop, val) { - if (typeof val === 'string') cacheLog.push(val) - t[prop] = val - return true - }, -}) -const idx = code.lastIndexOf(', []);') -code = code.slice(0, idx) + ', LOGCACHE);' - -// ---- lx 环境(复刻 preload.js) ---- -const handlers = {} -const requestCalls = [] -let rconfigBody = process.env.RCONFIG_BODY ?? JSON.stringify({ code: 0, msg: 'success', data: 'https://example.test/fake.mp3' }) -const lx = { - EVENT_NAMES: { request: 'request', inited: 'inited', updateAlert: 'updateAlert' }, - request(url, options = {}, callback) { - const rec = { url, options: { method: options.method ?? 'get', timeout: options.timeout, headers: options.headers, body: options.body, form: options.form, formData: options.formData } } - requestCalls.push(rec) - console.log('[lx.request]', url) - console.log(' options:', JSON.stringify(rec.options)) - const isRconfig = url.includes('/rconfig') - const raw = isRconfig ? rconfigBody : JSON.stringify({ code: 0, msg: 'success', data: 'https://example.test/fake.mp3' }) - const parsed = (() => { try { return JSON.parse(raw) } catch { return raw } })() - setTimeout(() => { - callback(null, { statusCode: 200, statusMessage: 'OK', headers: { 'content-type': 'application/json' }, raw: Buffer.from(raw), body: parsed }, parsed) - }, 5) - return () => {} - }, - send(eventName, data) { - console.log('[lx.send]', eventName, JSON.stringify(data)) - return Promise.resolve() - }, - on(eventName, handler) { - console.log('[lx.on]', eventName, typeof handler, handler && handler.length) - handlers[eventName] = handler - return Promise.resolve() - }, - utils: { - crypto: { - aesEncrypt(b, mode, key, iv) { const c = crypto.createCipheriv(mode, key, iv); return Buffer.concat([c.update(b), c.final()]) }, - rsaEncrypt(b, key) { b = Buffer.concat([Buffer.alloc(128 - b.length), b]); return crypto.publicEncrypt({ key, padding: crypto.constants.RSA_NO_PADDING }, b) }, - randomBytes: (n) => crypto.randomBytes(n), - md5, - }, - buffer: { from: (...a) => Buffer.from(...a), bufToString: (b, f) => Buffer.from(b, 'binary').toString(f) }, - zlib: { inflate: (b) => inflate(b), deflate: (d) => deflate(d) }, - }, - currentScriptInfo: { name: '独家音源', description: '', version: '5', author: 'w', homepage: '', rawScript: code }, - version: '2.0.0', - env: 'desktop', -} - -const ctx = { - console, setTimeout, clearTimeout, setInterval, clearInterval, - Promise, Error, TypeError, RangeError, SyntaxError, EvalError, URIError, - Array, Object, String, Number, Boolean, Date, Math, JSON, RegExp, BigInt, Symbol, - Function, parseInt, parseFloat, isNaN, isFinite, - encodeURIComponent, decodeURIComponent, escape, unescape, - Uint8Array, Uint16Array, Uint32Array, Int8Array, Int16Array, Int32Array, Float32Array, Float64Array, ArrayBuffer, DataView, Map, Set, WeakMap, WeakSet, - TextEncoder, TextDecoder, URL, URLSearchParams, - Buffer, - atob: (s) => Buffer.from(s, 'base64').toString('binary'), - btoa: (s) => Buffer.from(s, 'binary').toString('base64'), - SCRIPT_MD5: md5(fs.readFileSync(obfPath)), - LOGCACHE, - lx, -} -ctx.globalThis = ctx -ctx.window = ctx -ctx.self = ctx -ctx.global = ctx - -process.on('uncaughtException', (e) => console.error('[uncaught]', e.stack?.split('\n').slice(0, 6).join('\n'))) -process.on('unhandledRejection', (e) => console.error('[unhandledRejection]', e?.stack?.split('\n').slice(0, 6).join('\n') || e)) - -const context = vm.createContext(ctx) -console.error('[run]') -try { - vm.runInContext(code, context, { filename: 'lx-source-v5.js', timeout: 15000 }) - console.error('[run ok]') -} catch (e) { - console.error('[vm error]', e.stack?.split('\n').slice(0, 8).join('\n')) -} - -await new Promise((r) => setTimeout(r, 1500)) - -console.error('\n===== decoded strings (%d) =====', cacheLog.length) -console.log(cacheLog.map((s, i) => `${i}\t${JSON.stringify(s)}`).join('\n')) - -console.error('\n===== summary =====') -console.error('events registered:', Object.keys(handlers)) -console.error('requests made:', requestCalls.length) - -// 触发 musicUrl 请求 -const handler = handlers.request -if (handler) { - console.error('\n[trigger] musicUrl kw/128k') - const markBefore = cacheLog.length - try { - const p = handler.call(lx, { source: 'kw', action: 'musicUrl', info: { type: '128k', musicInfo: { songmid: '28423286', hash: 'fakehash123', id: 'kw_28423286' } } }) - console.error('[trigger] handler returned:', p && p.constructor?.name) - if (p && typeof p.then === 'function') { - const res = await Promise.race([p.then((v) => ({ ok: true, v }), (e) => ({ ok: false, msg: e?.message, name: e?.name })), new Promise((r) => setTimeout(() => r({ timeout: true }), 8000))]) - console.error('[trigger result]', JSON.stringify(res, null, 1)) - } - } catch (e) { - console.error('[trigger sync error]', e?.message) - } - console.error('\n[strings used during trigger]') - console.error(cacheLog.slice(markBefore).map((s) => JSON.stringify(s)).join('\n')) - await new Promise((r) => setTimeout(r, 500)) - console.error('\n[requests during trigger]') - for (const rc of requestCalls) console.log(rc.url, JSON.stringify(rc.options)) -} -fs.writeFileSync(fileURLToPath(new URL('./trace-strings.json', import.meta.url)), JSON.stringify(cacheLog, null, 1)) -process.exit(0) diff --git a/tmp/test-lx-source-ivm.mjs b/tmp/test-lx-source-ivm.mjs deleted file mode 100644 index 3398f1e..0000000 --- a/tmp/test-lx-source-ivm.mjs +++ /dev/null @@ -1,81 +0,0 @@ -// isolated-vm 加载器测试(离线,不真实联网) -// 运行:node tmp/test-lx-source-ivm.mjs -import { createIvmLxSource } from '../src/apis/lx-source-ivm.js' - -let failed = 0 -const check = (name, cond, detail = '') => { - console.log(`${cond ? '✅' : '❌'} ${name}${detail ? ` — ${detail}` : ''}`) - if (!cond) failed++ -} - -const makeMock = () => { - const seenUrls = [] - const mock = (url) => { - seenUrls.push(url) - return { - promise: Promise.resolve({ - statusCode: 200, statusMessage: 'OK', - headers: { 'content-type': 'application/json' }, - body: { code: 0, msg: 'success', data: 'https://example.com/fake.mp3' }, - }), - cancelHttp() {}, - } - } - mock.seenUrls = seenUrls - return mock -} - -// ===== 常规流程(默认白名单:脚本 apiUrl 同源放行)===== -const mock = makeMock() -const lx = await createIvmLxSource({ http: mock }) - -check('脚本 inited 上报', !!lx.inited, JSON.stringify(lx.inited?.sources ? Object.keys(lx.inited.sources) : null)) -check('scriptInfo 解析', lx.scriptInfo.version === '5', JSON.stringify(lx.scriptInfo)) -check('白名单来自脚本声明', lx.allowedOrigins.includes('https://88.lxmusic.xn--fiqs8s'), lx.allowedOrigins.join(',')) - -// hash 优先 -const r1 = lx.apis.kw.getMusicUrl({ songmid: '28423286', hash: 'fakehash123' }, '320k') -const u1 = await r1.promise -check('musicUrl hash 优先', u1.url === 'https://example.com/fake.mp3', u1.url) -check('取流路径/hash', mock.seenUrls.some(u => u.includes('/v4s/url/kw/fakehash123/320k')), mock.seenUrls.at(-1)?.split('?')[0]) - -// songmid 兜底 -const r2 = lx.apis.tx.getMusicUrl({ songmid: '003C9fBv2K4x8b' }, '128k') -const u2 = await r2.promise -check('musicUrl songmid 兜底', u2.url === 'https://example.com/fake.mp3', u2.url) -check('取流路径/songmid', mock.seenUrls.some(u => u.includes('/v4s/url/tx/003C9fBv2K4x8b/128k'))) - -// 签名参数齐全 -const lastUrl = mock.seenUrls.at(-1) -check('签名参数齐全', ['e=', 'to=', 'fp=', 's2=', 'v=5', 'si=', 's3='].every(k => lastUrl.includes(k))) - -// rconfig 心跳(脚本加载时自动发出,走白名单放行) -check('启动 rconfig 心跳放行', mock.seenUrls.some(u => u.includes('/rconfig?'))) - -// ===== HTTP 默认阻止:白名单置空,脚本请求全部拦截 ===== -const mockBlocked = makeMock() -const lb = await createIvmLxSource({ http: mockBlocked, allowedOrigins: [], initTimeout: 8000, log: false }) -let blockedBehavior = 'init-failed' -try { - const rb = lb.apis.kw.getMusicUrl({ songmid: '28423286' }, '128k') - await rb.promise - blockedBehavior = 'unexpected-success' -} catch (e) { - blockedBehavior = `rejected: ${e.message}` -} -check('白名单置空后请求被拦截', !mockBlocked.seenUrls.length, `mock 被调用 ${mockBlocked.seenUrls.length} 次; ${blockedBehavior}`) -await lb.destroy() - -// ===== 失败路径:code != 0 → 脚本内部错误码映射后 reject ===== -let errSeen = '' -const mockFail = () => ({ promise: Promise.resolve({ statusCode: 200, statusMessage: 'OK', headers: {}, body: { code: 1, msg: '签名过期' } }), cancelHttp() {} }) -const lx2 = await createIvmLxSource({ http: mockFail, log: false }) -try { - await lx2.apis.kg.getMusicUrl({ hash: 'abc' }, 'flac').promise -} catch (e) { errSeen = e.message } -check('code!=0 reject(脚本错误映射)', !!errSeen && errSeen !== 'undefined', errSeen) -await lx2.destroy() - -await lx.destroy() -console.log(failed ? `\n${failed} 项失败` : '\n全部通过') -process.exit(failed ? 1 : 0) diff --git a/tmp/trace-strings.json b/tmp/trace-strings.json deleted file mode 100644 index 1bcf4b4..0000000 --- a/tmp/trace-strings.json +++ /dev/null @@ -1,261 +0,0 @@ -[ - "ps://88.lxmusi", - "c.xn--fiqs8s", - "ci", - "sumxl", - "par", - "es", - "}]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"gm\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"yw\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"xt\",]\"tib42calf\",\"calf\",\"k023\",\"k821\"[:\"gk\",]\"tib42calf\",\"calf\",\"k023\",\"k8", - "21\"[:\"wk\"{", - "sye", - "k", - "hs", - "up", - "l", - "acol", - "x", - "SEMAN_", - "TNEVE", - "tseuq", - "e", - "r", - "n", - "o", - "d", - "sli", - "vn", - "noisre", - "v", - "GIFNOC_TPIRCS_REVR", - "ES", - "litu", - "otpy", - "rc", - "m", - "5", - "ofnItpircStn", - "erruc", - "rev", - "sion", - "undefin", - "ed", - "u", - "ndefined", - "noitc", - "nuf", - "ro", - "tareti", - "gaTgnirt", - "Sot", - "getPrototype", - "Of", - "pytotorp", - "prot", - "et", - "aerc", - "epytoto", - "rp", - "construct", - "or", - "ytreporPeni", - "f", - "", - "value", - "enumerable", - "configurable", - "writable", - "rotcur", - "tsnoc", - "emaNy", - "alp", - "sid", - "oitcnuFrotareneG", - "noitcnuFro", - "tareneG", - "xt", - "wor", - "h", - "nrute", - "rotar", - "eneG", - "gn", - "irtSot", - "w", - "fOepytoto", - "fOe", - "p", - "ytotorPtes", - "rototype", - "eta", - "erc", - "appl", - "prototy", - "pe", - "eate", - "protot", - "epy", - "_invok", - "a", - "in", - "_i", - "nvoke", - "lac", - "app", - "ly", - "tacn", - "oc", - "/rc", - "onfig", - "T", - "G", - "method", - "nos", - "j/noitacilppa", - "Content-Type", - "co", - "-cisum-", - "lx", - "conca", - "/", - "t", - "acnoc", - "User-Agent", - "headers", - "follow_max", - "length", - "on", - "toStrin", - "g", - "ush", - "ni", - "oj", - "toStri", - "ahc", - "rCodeAt", - "ot", - "gnir", - "0", - "taep", - "er", - "ecolons", - "time", - "lps", - "it", - "am", - "tch", - "^https?:\\/\\/[^/]+(.*)$", - "sedul", - "c", - "?", - "fingerprin", - "current", - "ScriptInfo", - "tpir", - "cSwar", - "toLowerCa", - "se", - "Nd7Sk/2Mj9s/", - "Vdk=", - "ilp", - "s", - "sup", - "jo", - "gnirtS", - "dap", - "Start", - "esaCreppUo", - "til", - "ps", - "ioj", - "​", - "tla", - "Sngis", - "|", - "tArah", - "charA", - "=", - "noloce", - "ot&", - "ti", - "&f", - "=p", - "=2s", - "&", - "&v", - "=is", - "si", - "gn3", - "done", - "valu", - "resolv", - "th", - "en", - "hcta", - "yd", - "ob", - "dat", - "tad", - "ause", - "Vnim", - "y", - "ilauQqh", - "hc", - "aErof", - "name", - "mus", - "type", - "ic", - "lrUci", - "sum", - "actions", - "qualitys", - "reque", - "st", - "i", - "detin", - "status", - "openDevTools", - "sources", - "no", - "itca", - "ec", - "ru", - "os", - "inf", - "lrUcis", - "um", - "fnIcisum", - "pyt", - "ppa", - "lo", - "cal", - "ha", - "sh", - "tac", - "/s4v/", - "noc", - "EG", - "application/jso", - "tacno", - "lx-music", - "-", - "source", - "songId", - "quality", - "sign3", - "sig", - "n3", - "ecr", - "3n", - "gnos", - "qu", - "ality", - "rever", - "=3", - "s&", - "3ngi", - "bo", - "cod", - "at", - "ad" -] \ No newline at end of file diff --git a/tmp/verify-real-network.mjs b/tmp/verify-real-network.mjs deleted file mode 100644 index 224fc01..0000000 --- a/tmp/verify-real-network.mjs +++ /dev/null @@ -1,10 +0,0 @@ -// 端到端验证:经 src/apis/lx-source.js 懒加载封装真实取流一次 -import lx from '../src/apis/lx-source.js' - -const r = lx.kw.getMusicUrl({ songmid: '28423286' }, '320k') -const res = await Promise.race([ - r.promise, - new Promise((_, rej) => setTimeout(() => rej(new Error('timeout 25s')), 25000)), -]) -console.log('[结果]', JSON.stringify(res).slice(0, 160)) -process.exit(0)