diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..a186fa2 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,108 @@ +# MusicServer 聚合音乐 API + +多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。 +支持平台:**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`(可生成各语言客户端) + +## 约定 + +- **成功**:`200` + `{"code": 0, "data": ...}` +- **失败**:对应 HTTP 状态码 + `{"code": <状态码>, "msg": "...", "detail": ...}` + - `422` 参数校验失败(缺失、非法音质、页码越界等) + - `404` 接口不存在 / 平台不支持该能力 + - `405` 方法不允许(仅支持 GET) + - `502` 上游平台请求失败 + - `500` 服务器内部错误 +- 服务器支持 CORS(`*`),仅接受 `GET`。 +- 每个资源接口同时提供**路径参数风格**(规范)与**query 风格**(旧版兼容,已在文档中标记 deprecated)。 +- 平台歌曲 id 的形态:kw/tx/wy/mg 用 `songmid`,kg 用 `hash`,mg 亦可用 `copyrightId`。 + +## 接口速查 + +### 系统 + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| 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 | `/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`,返回歌曲列表 | + +旧版兼容:`/boards?source=`(列表)、`/board?source=&id=&page=`(详情)。 + +### 歌单 & 热搜(规范路由) + +| 方法 | 路径 | 关键参数 | +| --- | --- | --- | +| GET | `/song-list/:source` | `page=1`、`sortId=3`、`tagId=0`(平台相关) | +| GET | `/hot-search/:source` | 无 | + +旧版兼容:`/songList?source=&page=&sortId=&tagId=`、`/hotSearch?source=`。 + +## 响应数据示例 + +```jsonc +// GET /search?source=kw&name=稻香&limit=2 +{ "code": 0, "data": { "source": "kw", "list": [ /* SongInfo */ ], "page": 1, "limit": 2 } } + +// 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 /url/kw/28423286?quality=320k +{ "code": 0, "data": { "type": "320k", "url": "http://..." } } + +// GET /lyric/kw/28423286 +{ "code": 0, "data": { "lyric": "[ti:稻香]...", "tlyric": "", "lxlyric": "..." } } + +// 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 +``` diff --git a/src/http/api-error.js b/src/http/api-error.js new file mode 100644 index 0000000..85dae07 --- /dev/null +++ b/src/http/api-error.js @@ -0,0 +1,25 @@ +// API 业务错误 —— 路由处理中抛出,由服务器统一转换为对应 HTTP 状态码的 JSON 响应 + +export class ApiError extends Error { + /** + * @param {number} status HTTP 状态码(同时作为响应体中的 code) + * @param {string} msg 错误描述 + * @param {unknown} [detail] 可选的结构化错误详情 + */ + constructor(status, msg, detail) { + super(msg) + this.name = 'ApiError' + this.status = status + this.code = status + this.detail = detail + } +} + +/** 参数校验失败(FastAPI 风格使用 422) */ +export const invalidParam = (msg, detail) => new ApiError(422, msg, detail) + +/** 资源不存在(未知路由 / 不支持的平台) */ +export const notFound = (msg, detail) => new ApiError(404, msg, detail) + +/** 上游平台请求失败 */ +export const upstream = (msg, detail) => new ApiError(502, msg, detail) diff --git a/src/http/router.js b/src/http/router.js new file mode 100644 index 0000000..99fe7cf --- /dev/null +++ b/src/http/router.js @@ -0,0 +1,53 @@ +// 极简 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/spec.js new file mode 100644 index 0000000..4100868 --- /dev/null +++ b/src/openapi/spec.js @@ -0,0 +1,281 @@ +// OpenAPI 3.1 文档生成 —— 从路由元数据构建 /openapi.json,供 Swagger UI(/docs)与 ReDoc(/redoc)使用 + +const json = (schema) => ({ 'application/json': { schema } }) +const ref = (name) => ({ $ref: `#/components/schemas/${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: '可选的结构化错误详情' }, + }, + required: ['code', 'msg'], + }, + QualityInfo: { + type: 'object', + properties: { size: { type: 'string', example: '9.8M' } }, + }, + SongType: { + type: 'object', + properties: { + type: { type: 'string', example: '320k', enum: ['128k', '320k', 'flac', 'flac24bit'] }, + size: { type: 'string', example: '9.8M' }, + }, + }, + SongInfo: { + type: 'object', + properties: { + songmid: { type: 'string', description: '平台歌曲 id(kg 平台为 hash)' }, + hash: { type: 'string', description: 'kg 平台的歌曲 hash' }, + copyrightId: { type: 'string', description: 'mg 平台的版权 id' }, + albumId: { type: 'string' }, + name: { type: 'string', description: '歌名' }, + singer: { type: 'string', description: '歌手' }, + source: { type: 'string', description: '来源平台 id' }, + interval: { type: 'string', description: '时长,格式 mm:ss' }, + albumName: { type: 'string' }, + img: { type: ['string', 'null'], description: '封面地址' }, + types: { type: 'array', items: ref('SongType'), description: '可用音质列表' }, + _types: { type: 'object', additionalProperties: ref('QualityInfo') }, + typeUrl: { type: 'object' }, + }, + }, + SearchResult: { + type: 'object', + properties: { + source: { type: 'string', description: '来源平台 id,聚合搜索时为 all' }, + list: { type: 'array', items: ref('SongInfo') }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + }, + }, + FindResult: { + type: 'object', + properties: { + list: { type: 'array', items: ref('SongInfo'), description: '按匹配度排序的歌曲列表' }, + }, + }, + MusicUrlResult: { + type: 'object', + properties: { + type: { type: 'string', example: '320k' }, + url: { type: 'string', description: '播放地址' }, + }, + }, + LyricInfo: { + type: 'object', + properties: { + lyric: { type: 'string', description: '原文歌词(LRC)' }, + tlyric: { type: 'string', description: '翻译歌词' }, + rlyric: { type: 'string', description: '罗马音歌词(部分平台)' }, + lxlyric: { type: 'string', description: '逐字歌词(部分平台)' }, + }, + required: ['lyric', 'tlyric'], + }, + PicResult: { + type: 'object', + properties: { url: { type: 'string', description: '封面地址' } }, + }, + BoardItem: { + type: 'object', + properties: { + id: { type: 'string', description: '榜单 id,用于榜单详情接口' }, + name: { type: 'string' }, + bangid: { type: 'string', description: '平台原始榜单 id' }, + }, + }, + BoardListResult: { + type: 'object', + properties: { list: { type: 'array', items: ref('BoardItem') } }, + }, + SongItem: { + type: 'object', + properties: { + singer: { type: 'string' }, + name: { type: 'string' }, + albumName: { type: 'string' }, + albumId: { type: 'string' }, + source: { type: 'string' }, + interval: { type: 'string' }, + songmid: { type: 'string' }, + hash: { type: 'string' }, + img: { type: 'string' }, + types: { type: 'array', items: ref('SongType') }, + }, + }, + BoardDetailResult: { + type: 'object', + properties: { + list: { type: 'array', items: ref('SongItem') }, + total: { type: 'integer' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + source: { type: 'string' }, + }, + }, + HotSearchResult: { + type: 'object', + properties: { + source: { type: 'string' }, + list: { type: 'array', items: { type: 'string' } }, + }, + }, + SongListSummary: { + type: 'object', + properties: { + play_count: { type: 'string', description: '播放量(已格式化)' }, + id: { type: 'string', description: '歌单 id' }, + author: { type: 'string' }, + name: { type: 'string' }, + time: { type: 'string' }, + img: { type: 'string' }, + total: { type: 'integer', description: '歌曲数' }, + desc: { type: 'string' }, + source: { type: 'string' }, + }, + }, + SongListResult: { + type: 'object', + properties: { + list: { type: 'array', items: ref('SongListSummary') }, + total: { type: 'integer' }, + page: { type: 'integer' }, + limit: { type: 'integer' }, + source: { type: 'string' }, + }, + }, + SourceInfo: { + type: 'object', + properties: { + id: { type: 'string', example: 'kw' }, + name: { type: 'string', example: '酷我音乐' }, + qualities: { type: 'array', items: { type: 'string' }, description: '支持的音质' }, + capabilities: { + type: 'object', + properties: { + search: { type: 'boolean' }, + url: { type: 'boolean' }, + lyric: { type: 'boolean' }, + pic: { type: 'boolean' }, + boards: { type: 'boolean' }, + hotSearch: { type: 'boolean' }, + songList: { type: 'boolean' }, + }, + }, + }, + }, + SourcesResult: { + type: 'object', + properties: { + platforms: { type: 'array', items: ref('SourceInfo') }, + }, + }, + HealthResult: { + type: 'object', + properties: { + status: { type: 'string', example: 'ok' }, + uptime: { type: 'integer', description: '运行秒数' }, + platforms: { type: 'integer', description: '已接入平台数' }, + }, + }, +} + +/** + * 从路由元数据构建 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 new file mode 100644 index 0000000..93bbe7e --- /dev/null +++ b/src/openapi/ui.js @@ -0,0 +1,44 @@ +// 文档页面 —— Swagger UI(/docs)与 ReDoc(/redoc),静态 HTML + CDN 资源 + +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) => ` + + + + + ${title} + ${body.head} + + + ${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 new file mode 100644 index 0000000..344a5a1 --- /dev/null +++ b/src/routes.js @@ -0,0 +1,462 @@ +// 路由表 —— 接口定义 + OpenAPI 元数据 + 处理函数 +// 处理函数返回普通对象 → 200 { code: 0, data } +// 抛出 ApiError → 对应 HTTP 状态码;抛出其他错误 → 500 +// 返回 { __raw: true, status, headers, body } → 原样输出(文档页/重定向用) + +import { invalidParam, notFound, upstream } from './http/api-error.js' + +const QUALITIES = ['128k', '320k', 'flac', 'flac24bit'] + +/** 统一解包 SDK 返回值:{ promise, cancelHttp } 结构或裸 Promise 兼容 */ +const unwrap = (result) => ( + result && typeof result.promise !== 'undefined' && typeof result.cancelHttp === 'function' + ? result.promise + : result +) + +const required = (query, names) => { + const missing = names.filter(n => !query[n]) + if (missing.length) throw invalidParam(`缺少参数: ${missing.join(', ')}`, { missing }) +} + +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 +} + +const getSource = (musicSdk, name, capability) => { + const m = musicSdk[name] + if (!m || (capability && !m[capability])) throw notFound(`不支持的平台: ${name}`) + 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 + */ +export const registerRoutes = (router, 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, + }, + 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 }) + + const doSearch = (id) => musicSdk[id].musicSearch + .search(`${name} ${singer || ''}`.trim(), page, limit) + .then(r => r.list) + + if (query.source) { + getSource(musicSdk, query.source, 'musicSearch') + try { + return { source: query.source, list: await doSearch(query.source), page, limit } + } catch (err) { + throw upstream(err.message) + } + } + + const lists = await Promise.all( + musicSdk.sources + .filter(s => s.id !== 'xm' && musicSdk[s.id]?.musicSearch) + .map(s => doSearch(s.id).catch(() => [])), + ) + 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' }, + }, + dataSchema: 'FindResult', + possible502: true, + }, async({ query }) => { + required(query, ['name']) + try { + return { + list: await musicSdk.findMusic({ + name: query.name, + singer: query.singer, + albumName: query.albumName, + interval: query.interval, + source: query.excludeSource, + }), + } + } catch (err) { + throw upstream(err.message) + } + }) + + // ---------- 歌曲资源(路径参数风格,含旧 query 风格别名) ---------- + + const urlHandler = ({ source, id, quality, albumId }) => { + if (!QUALITIES.includes(quality)) throw invalidParam(`不支持的音质: ${quality}`, { enum: QUALITIES }) + const m = getSource(musicSdk, source, 'getMusicUrl') + const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} } + let result + try { + result = unwrap(m.getMusicUrl(songInfo, quality)) + } catch (err) { + throw upstream(err.message) + } + return result.then(res => { + if (!res?.url) throw upstream('获取播放地址失败') + return { type: quality, url: res.url } + }).catch(err => { + throw err instanceof Error && 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' }, + }, + 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, + })) + + router.get('/url', { + summary: '获取播放地址(旧版 query 风格)', + tags: ['歌曲资源'], + deprecated: true, + queryParams: { + source: { ...pSource('url'), required: true }, + id: { required: true, description: '平台歌曲 id' }, + quality: pQuality, + albumId: { description: '专辑 id' }, + }, + dataSchema: 'MusicUrlResult', + possible404: true, + possible502: true, + }, ({ query }) => urlHandler({ + source: query.source, id: query.id, quality: query.quality || '128k', albumId: query.albumId, + })) + + const lyricHandler = ({ source, id, name, interval }) => { + const m = getSource(musicSdk, source, 'getLyric') + let result + try { + result = unwrap(m.getLyric({ songmid: id, hash: id, copyrightId: id, name: name || '', interval: interval || '' })) + } catch (err) { + throw upstream(err.message) + } + return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => { + throw err instanceof Error && err.status ? err : upstream(err.message) + }) + } + + router.get('/lyric/:source/:id', { + summary: '获取歌词', + tags: ['歌曲资源'], + pathParams: { + source: pSource('lyric'), + id: { description: '平台歌曲 id', example: '28423286' }, + }, + 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, + })) + + 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)' }, + }, + dataSchema: 'LyricInfo', + possible404: true, + possible502: true, + }, ({ query }) => lyricHandler({ + source: query.source, id: query.id, name: query.name, interval: query.interval, + })) + + const picHandler = ({ source, id, albumId }) => { + const m = getSource(musicSdk, source, 'getPic') + let result + try { + result = unwrap(m.getPic({ songmid: id, hash: id, copyrightId: id, albumId: albumId || '' })) + } catch (err) { + throw upstream(err.message) + } + return result.then(url => ({ url })).catch(err => { + throw err instanceof Error && err.status ? err : upstream(err.message) + }) + } + + router.get('/pic/:source/:id', { + summary: '获取封面', + tags: ['歌曲资源'], + pathParams: { + source: pSource('pic'), + id: { description: '平台歌曲 id', example: '28423286' }, + }, + queryParams: { + albumId: { description: '专辑 id(kg 平台需要)' }, + }, + dataSchema: 'PicResult', + possible404: true, + possible502: true, + }, ({ params, query }) => picHandler({ + source: params.source, id: params.id, albumId: query.albumId, + })) + + router.get('/pic', { + summary: '获取封面(旧版 query 风格)', + tags: ['歌曲资源'], + deprecated: true, + queryParams: { + source: { ...pSource('pic'), required: true }, + id: { required: true, description: '平台歌曲 id' }, + albumId: { description: '专辑 id' }, + }, + dataSchema: 'PicResult', + possible404: true, + possible502: true, + }, ({ query }) => picHandler({ + source: query.source, id: query.id, albumId: query.albumId, + })) + + // ---------- 榜单 ---------- + + const boardsHandler = (source) => { + const m = getSource(musicSdk, 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 }) => { + const m = getSource(musicSdk, source, 'leaderboard') + return m.leaderboard.getList(id, page).catch(err => { throw upstream(err.message) }) + } + + router.get('/boards/:source/:boardId', { + summary: '获取榜单详情', + tags: ['榜单'], + pathParams: { + source: pSource('boards'), + boardId: { description: '榜单 id(来自榜单列表接口)', example: 'tx__26' }, + }, + 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 }), + })) + + router.get('/board', { + summary: '获取榜单详情(旧版 query 风格)', + tags: ['榜单'], + deprecated: true, + queryParams: { + source: { ...pSource('boards'), required: true }, + id: { required: true, description: '榜单 id' }, + page: pPage, + }, + 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 }), + }) + }) + + // ---------- 歌单 & 热搜 ---------- + + const hotSearchHandler = (source) => { + const m = getSource(musicSdk, 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 }) => { + const m = getSource(musicSdk, source, 'songList') + return m.songList.getList(sortId ?? 3, tagId ?? 0, 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 }), + }) + }) + + // ---------- 系统 ---------- + + router.get('/health', { + summary: '健康检查', + tags: ['系统'], + dataSchema: 'HealthResult', + }, () => ({ + 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', + }, () => ({ + platforms: musicSdk.sources.map(({ id, name }) => { + const m = musicSdk[id] || {} + const qualities = [...new Set( + Object.values(musicSdk.supportQuality || {}) + .map(q => q[id]) + .filter(Boolean) + .flat(), + )] + return { + id, + name, + qualities, + capabilities: { + search: Boolean(m.musicSearch), + url: Boolean(m.getMusicUrl), + lyric: Boolean(m.getLyric), + pic: Boolean(m.getPic), + boards: Boolean(m.leaderboard), + hotSearch: Boolean(m.hotSearch), + songList: Boolean(m.songList), + }, + } + }), + })) +} diff --git a/src/server.js b/src/server.js index a10ffea..196192c 100644 --- a/src/server.js +++ b/src/server.js @@ -1,296 +1,177 @@ -// HTTP 服务器 —— 多平台音乐聚合 API -// 接口设计参考 lx-music-api-server 的路径约定: -// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略则聚合多平台) -// GET /find?name=&singer=&albumName=&interval= 精确匹配搜索(跨平台去重排序) -// GET /url?source=&id=&quality= 获取播放地址(id 为平台歌曲 id,如 songmid/hash/copyrightId) -// GET /lyric?source=&id=&trans=0 获取歌词 -// GET /pic?source=&id= 获取封面 -// GET /boards?source= 榜单列表 -// GET /board?source=&id=&page= 榜单详情 -// GET /hotSearch?source= 热搜 -// GET /songList?source=&page= 歌单列表 -// GET /health 健康检查 -// 返回格式:{ code: 0, data: ... } 或 { code: 非0, msg: ... } +// HTTP 服务器入口 —— 多平台音乐聚合 API +// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档 +// +// 规范路由(路径参数风格) 旧版路由(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 /sources /health GET /docs /redoc /openapi.json +// +// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误 +// 响应信封:成功 { code: 0, data };失败 { code: , msg, detail? } import http from 'node:http' +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' const PORT = process.env.PORT || 3300 +const API_TITLE = 'MusicServer 聚合音乐 API' +const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' + + '支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。' -// 统一响应 -const setJsonHeaders = (res, status = 200) => { - res.writeHead(status, { - 'Content-Type': 'application/json; charset=utf-8', - 'Access-Control-Allow-Origin': '*', - 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', - 'Access-Control-Allow-Headers': 'Content-Type', +const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8')) + +// ---- 路由装配 ---- + +const router = new Router() +registerRoutes(router, musicSdk) + +let specCache = null +const openApiSpec = () => { + if (!specCache) specCache = buildOpenApi(router, { + title: API_TITLE, + description: API_DESCRIPTION, + version: pkg.version, }) + return specCache } -const ok = (res, data) => { +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 - setJsonHeaders(res, 200) - res.end(JSON.stringify({ code: 0, data })) -} -const fail = (res, msg, code = 1) => { - if (res.writableEnded) return - setJsonHeaders(res, 200) - res.end(JSON.stringify({ code, msg: String(msg || '未知错误') })) + res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' }) + res.end(JSON.stringify(payload)) } -// 音质映射(SDK 内类型) -const qualitys = ['128k', '320k', 'flac', 'flac24bit'] - -/** - * 统一解包 SDK 返回值:getMusicUrl/getLyric/getPic 等返回 { promise, cancelHttp } 结构, - * 个别平台(如 mg/pic)返回 Promise,兼容两种形态 - */ -const unwrap = async(result) => { - if (result && typeof result.promise !== 'undefined' && typeof result.cancelHttp === 'function') { - return result.promise - } - return result -} - -/** - * 解析搜索/列表结果,得到歌曲对象列表 - * @param {object} source 平台 SDK 对象(musicSdk[kw/kg/tx/wy/mg/bd]) - * @param {string} name 歌名 - * @param {string} singer 歌手 - * @param {number} page - * @param {number} limit - */ -const searchSource = async(sourceName, source, name, singer, page, limit) => { - const result = await source.musicSearch.search(`${name} ${singer || ''}`.trim(), page, limit) - return result.list -} - -const routes = { - /** - * 搜索歌曲 - * GET /search?name=xxx&singer=xxx&source=kw&page=1&limit=20 - * source 省略时聚合所有平台(排除 xm) - */ - async search(req, res, params) { - const { name, singer } = params - if (!name) return fail(res, '缺少参数 name') - const page = parseInt(params.page || 1) - const limit = parseInt(params.limit || 20) - const sourceName = params.source - - if (sourceName) { - const source = musicSdk[sourceName] - if (!source?.musicSearch) return fail(res, `不支持的平台: ${sourceName}`) - try { - const list = await searchSource(sourceName, source, name, singer, page, limit) - return ok(res, { source: sourceName, list, page, limit }) - } catch (err) { - return fail(res, err.message) - } - } - - // 聚合搜索 - const tasks = [] - for (const s of musicSdk.sources) { - if (!musicSdk[s.id]?.musicSearch || s.id == 'xm') continue - tasks.push(searchSource(s.id, musicSdk[s.id], name, singer, page, limit) - .catch(() => [])) - } - const lists = await Promise.all(tasks) - const list = lists.flat() - return ok(res, { source: 'all', list, page, limit }) - }, - - /** - * 精确匹配搜索(返回最匹配项,跨平台聚合) - * GET /find?name=xxx&singer=xxx&albumName=xxx&interval=xxx - */ - async find(req, res, params) { - if (!params.name) return fail(res, '缺少参数 name') - try { - const list = await musicSdk.findMusic(params) - return ok(res, { list }) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取播放地址 - * GET /url?source=kw&id=xxx&quality=320k - * id 为平台歌曲 id(kw/tx/wy/mg/bd 用 songmid,kg 用 hash) - */ - async url(req, res, params) { - const { source, id } = params - if (!source || !id) return fail(res, '缺少参数 source 或 id') - const quality = qualitys.includes(params.quality) ? params.quality : '128k' - const sourceObj = musicSdk[source] - if (!sourceObj?.getMusicUrl) return fail(res, `不支持的平台: ${source}`) - - const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: params.albumId || '', _types: {} } - try { - const result = await unwrap(sourceObj.getMusicUrl(songInfo, quality)) - if (!result?.url) return fail(res, '获取失败') - return ok(res, { type: quality, url: result.url }) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取歌词 - * GET /lyric?source=kw&id=xxx&trans=0 - * id 为平台歌曲 id - */ - async lyric(req, res, params) { - const { source, id } = params - if (!source || !id) return fail(res, '缺少参数 source 或 id') - const sourceObj = musicSdk[source] - if (!sourceObj?.getLyric) return fail(res, `不支持的平台: ${source}`) - try { - const result = await unwrap(sourceObj.getLyric({ songmid: id, hash: id, copyrightId: id, name: params.name || '', interval: params.interval || '' })) - return ok(res, result || { lyric: '', tlyric: '' }) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取封面 - * GET /pic?source=kw&id=xxx - */ - async pic(req, res, params) { - const { source, id } = params - if (!source || !id) return fail(res, '缺少参数 source 或 id') - const sourceObj = musicSdk[source] - if (!sourceObj?.getPic) return fail(res, `不支持的平台: ${source}`) - try { - const url = await unwrap(sourceObj.getPic({ songmid: id, hash: id, copyrightId: id, albumId: params.albumId || '' })) - return ok(res, { url }) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取榜单列表 - * GET /boards?source=kw - */ - async boards(req, res, params) { - const { source } = params - if (!source) return fail(res, '缺少参数 source') - const sourceObj = musicSdk[source] - if (!sourceObj?.leaderboard) return fail(res, `不支持的平台: ${source}`) - try { - const result = await sourceObj.leaderboard.getBoards() - return ok(res, result) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取榜单详情 - * GET /board?source=kw&id=xxx&page=1 - */ - async board(req, res, params) { - const { source, id } = params - if (!source || !id) return fail(res, '缺少参数 source 或 id') - const sourceObj = musicSdk[source] - if (!sourceObj?.leaderboard) return fail(res, `不支持的平台: ${source}`) - try { - const page = parseInt(params.page || 1) - const result = await sourceObj.leaderboard.getList(id, page) - return ok(res, result) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 获取热搜 - * GET /hotSearch?source=kw - */ - async hotSearch(req, res, params) { - const { source } = params - if (!source) return fail(res, '缺少参数 source') - const sourceObj = musicSdk[source] - if (!sourceObj?.hotSearch) return fail(res, `不支持的平台: ${source}`) - try { - const result = await sourceObj.hotSearch.getList() - return ok(res, result) - } catch (err) { - return fail(res, err.message) - } - }, - - /** - * 歌单列表 - * GET /songList?source=kw&page=1 - */ - async songList(req, res, params) { - const { source } = params - if (!source) return fail(res, '缺少参数 source') - const sourceObj = musicSdk[source] - if (!sourceObj?.songList) return fail(res, `不支持的平台: ${source}`) - try { - const page = parseInt(params.page || 1) - const result = await sourceObj.songList.getList(params.sortId || 3, params.tagId || 0, page) - return ok(res, result) - } catch (err) { - return fail(res, err.message) - } - }, - - /** 健康检查 */ - async health(req, res) { - return ok(res, { status: 'ok' }) - }, +const logRequest = (req, pathname, search, status, ms) => { + console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}${search} → ${status} (${ms}ms)`) } const handler = async(req, res) => { - // OPTIONS 预检请求快速响应 + 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') { - setJsonHeaders(res, 204) + res.writeHead(204) res.end() + logRequest(req, req.url, '', 204, Date.now() - started) return } - const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`) - const path = parsedUrl.pathname.replace(/^\/+|\/+$/g, '') - const params = Object.fromEntries(parsedUrl.searchParams.entries()) + const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`) + const query = Object.fromEntries(url.searchParams.entries()) + let status = 500 - const route = routes[path] - if (!route) { - setJsonHeaders(res, 404) - res.end(JSON.stringify({ code: 404, msg: 'Not Found' })) - return - } try { - await route(req, res, params) + 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 (process.env.DEBUG) { - console.error('[route error]', req.url, err?.message, err?.stack?.split('\n').slice(0, 4).join('\n')) + 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: '服务器内部错误' }) } - fail(res, err.message) } + + 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('可用接口: /search /find /url /lyric /pic /boards /board /hotSearch /songList /health') + 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')) diff --git a/src/smoke-test.js b/src/smoke-test.js index 237be93..4d84177 100644 --- a/src/smoke-test.js +++ b/src/smoke-test.js @@ -1,7 +1,8 @@ // 冒烟测试 —— 验证服务器各接口与各平台功能 // 运行:node src/smoke-test.js // -// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜 +// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜、 +// 标准 HTTP 语义(404/405/422/302)、文档(/docs /openapi.json /sources) import http from 'node:http' import { spawn } from 'node:child_process' @@ -19,6 +20,25 @@ const get = (path) => new Promise((resolve, reject) => { }).on('error', reject) }) +// 原始响应(不解析 JSON,用于 HTML 页与状态码断言) +const getRaw = (path) => new Promise((resolve, reject) => { + http.get(`${BASE}${path}`, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })) + }).on('error', reject) +}) + +const postRaw = (path) => new Promise((resolve, reject) => { + const req = http.request(`${BASE}${path}`, { method: 'POST' }, res => { + let data = '' + res.on('data', c => { data += c }) + res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: data })) + }) + req.on('error', reject) + req.end() +}) + const test = async(name, fn) => { try { const result = await fn() @@ -156,6 +176,89 @@ await test('热搜 kw', async() => { return r.data.list[0] }) +// ---- 标准 HTTP 语义与文档(新版路由) ---- + +await test('平台列表 /sources', async() => { + const r = await get('/sources') + if (r.code != 0) throw new Error(r.msg) + if (!r.data?.platforms?.length) throw new Error('空平台列表') + const kw = r.data.platforms.find(p => p.id === 'kw') + if (!kw?.capabilities?.search) throw new Error('kw 缺少 search 能力') + 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('参数校验 422(缺少 name)', async() => { + const r = await getRaw('/search') + if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) + const body = JSON.parse(r.body) + if (body.code !== 422 || !body.msg) throw new Error('响应体格式错误') + return body.msg +}) + +await test('参数校验 422(非法音质)', async() => { + const r = await getRaw('/url/kw/28423286?quality=999k') + if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`) + return JSON.parse(r.body).msg +}) + +await test('404 未知接口', async() => { + const r = await getRaw('/not-exist') + if (r.status !== 404) throw new Error(`期望 404,实际 ${r.status}`) + return JSON.parse(r.body).msg +}) + +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('根路径 302 → /docs', async() => { + const r = await getRaw('/') + if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`) + if (r.headers.location !== '/docs') throw new Error(`期望 /docs,实际 ${r.headers.location}`) + return r.headers.location +}) + +await test('Swagger UI 文档页 /docs', async() => { + const r = await getRaw('/docs') + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + if (!r.body.includes('swagger-ui')) throw new Error('页面内容异常') + return 'Swagger UI' +}) + +await test('OpenAPI 规范 /openapi.json', async() => { + const r = await getRaw('/openapi.json') + if (r.status !== 200) throw new Error(`期望 200,实际 ${r.status}`) + 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('缺少路径参数路由定义') + return `${Object.keys(spec.paths).length} 个路径` +}) + // 汇总 const passed = results.filter(r => r.ok).length const failed = results.filter(r => !r.ok) diff --git a/tmp/check-openapi.mjs b/tmp/check-openapi.mjs new file mode 100644 index 0000000..f7e2f53 --- /dev/null +++ b/tmp/check-openapi.mjs @@ -0,0 +1,25 @@ +// 临时验证脚本 —— 检查 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)