feat(server): 标准 HTTP 服务器重构 + FastAPI 风格自动文档
This commit is contained in:
1 parent
1dfb7a59ca
commit
59b2aae584
9 files changed
+1236
-254
No files matched your search
+108
@@ -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
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
@@ -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<string,string> } | { 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
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) => `<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>${title}</title>
|
||||||
|
${body.head}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
${body.content}
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
|
||||||
|
export const swaggerHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(title, {
|
||||||
|
head: `
|
||||||
|
<link rel="stylesheet" href="${SWAGGER_CDN}">
|
||||||
|
<style>body { margin: 0; }</style>`,
|
||||||
|
content: `
|
||||||
|
<div id="swagger-ui"></div>
|
||||||
|
<script src="${SWAGGER_BUNDLE_CDN}" crossorigin></script>
|
||||||
|
<script>
|
||||||
|
window.onload = () => window.ui = SwaggerUIBundle({
|
||||||
|
url: '${specUrl}',
|
||||||
|
dom_id: '#swagger-ui',
|
||||||
|
docExpansion: 'none',
|
||||||
|
persistAuthorization: true,
|
||||||
|
tryItOutEnabled: true,
|
||||||
|
})
|
||||||
|
</script>`,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const redocHtml = ({ title, specUrl = '/openapi.json' }) => htmlShell(`${title} (ReDoc)`, {
|
||||||
|
head: `
|
||||||
|
<style>body { margin: 0; padding: 0; }</style>`,
|
||||||
|
content: `
|
||||||
|
<redoc spec-url="${specUrl}" hide-download-button="true"></redoc>
|
||||||
|
<script src="${REDOC_CDN}"></script>`,
|
||||||
|
})
|
||||||
+462
@@ -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),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
}
|
||||||
+134
-253
@@ -1,296 +1,177 @@
|
|||||||
// HTTP 服务器 —— 多平台音乐聚合 API
|
// HTTP 服务器入口 —— 多平台音乐聚合 API
|
||||||
// 接口设计参考 lx-music-api-server 的路径约定:
|
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档
|
||||||
// GET /search?name=&singer=&source=&page=&limit= 搜索(source 省略则聚合多平台)
|
//
|
||||||
// GET /find?name=&singer=&albumName=&interval= 精确匹配搜索(跨平台去重排序)
|
// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容)
|
||||||
// GET /url?source=&id=&quality= 获取播放地址(id 为平台歌曲 id,如 songmid/hash/copyrightId)
|
// GET /search (同左)
|
||||||
// GET /lyric?source=&id=&trans=0 获取歌词
|
// GET /find (同左)
|
||||||
// GET /pic?source=&id= 获取封面
|
// GET /url/:source/:id GET /url?source=&id=
|
||||||
// GET /boards?source= 榜单列表
|
// GET /lyric/:source/:id GET /lyric?source=&id=
|
||||||
// GET /board?source=&id=&page= 榜单详情
|
// GET /pic/:source/:id GET /pic?source=&id=
|
||||||
// GET /hotSearch?source= 热搜
|
// GET /boards/:source GET /boards?source=
|
||||||
// GET /songList?source=&page= 歌单列表
|
// GET /boards/:source/:boardId GET /board?source=&id=
|
||||||
// GET /health 健康检查
|
// GET /hot-search/:source GET /hotSearch?source=
|
||||||
// 返回格式:{ code: 0, data: ... } 或 { code: 非0, msg: ... }
|
// 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: <HTTP状态码>, msg, detail? }
|
||||||
|
|
||||||
import http from 'node:http'
|
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 { URL } from 'node:url'
|
||||||
import musicSdk from '../musicSdk/index.js'
|
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 PORT = process.env.PORT || 3300
|
||||||
|
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 setJsonHeaders = (res, status = 200) => {
|
|
||||||
res.writeHead(status, {
|
// ---- 路由装配 ----
|
||||||
'Content-Type': 'application/json; charset=utf-8',
|
|
||||||
'Access-Control-Allow-Origin': '*',
|
const router = new Router()
|
||||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
registerRoutes(router, musicSdk)
|
||||||
'Access-Control-Allow-Headers': 'Content-Type',
|
|
||||||
|
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
|
if (res.writableEnded) return
|
||||||
setJsonHeaders(res, 200)
|
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
|
||||||
res.end(JSON.stringify({ code: 0, data }))
|
res.end(JSON.stringify(payload))
|
||||||
}
|
|
||||||
const fail = (res, msg, code = 1) => {
|
|
||||||
if (res.writableEnded) return
|
|
||||||
setJsonHeaders(res, 200)
|
|
||||||
res.end(JSON.stringify({ code, msg: String(msg || '未知错误') }))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 音质映射(SDK 内类型)
|
const logRequest = (req, pathname, search, status, ms) => {
|
||||||
const qualitys = ['128k', '320k', 'flac', 'flac24bit']
|
console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}${search} → ${status} (${ms}ms)`)
|
||||||
|
|
||||||
/**
|
|
||||||
* 统一解包 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 handler = async(req, res) => {
|
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') {
|
if (req.method === 'OPTIONS') {
|
||||||
setJsonHeaders(res, 204)
|
res.writeHead(204)
|
||||||
res.end()
|
res.end()
|
||||||
|
logRequest(req, req.url, '', 204, Date.now() - started)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
|
||||||
const path = parsedUrl.pathname.replace(/^\/+|\/+$/g, '')
|
const query = Object.fromEntries(url.searchParams.entries())
|
||||||
const params = Object.fromEntries(parsedUrl.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 {
|
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) {
|
} catch (err) {
|
||||||
if (process.env.DEBUG) {
|
if (err instanceof ApiError) {
|
||||||
console.error('[route error]', req.url, err?.message, err?.stack?.split('\n').slice(0, 4).join('\n'))
|
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) => {
|
export const startServer = (port = PORT) => {
|
||||||
const server = http.createServer(handler)
|
const server = http.createServer(handler)
|
||||||
server.listen(port, () => {
|
server.listen(port, () => {
|
||||||
console.log(`音乐服务器已启动: http://localhost:${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) => {
|
const shutdown = (signal) => {
|
||||||
console.log(`\n收到 ${signal},正在关闭服务器...`)
|
console.log(`\n收到 ${signal},正在关闭服务器...`)
|
||||||
server.close(() => {
|
server.close(() => {
|
||||||
console.log('服务器已关闭')
|
console.log('服务器已关闭')
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})
|
})
|
||||||
|
server.closeAllConnections?.()
|
||||||
|
setTimeout(() => process.exit(0), 3000).unref()
|
||||||
}
|
}
|
||||||
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
process.on('SIGTERM', () => shutdown('SIGTERM'))
|
||||||
process.on('SIGINT', () => shutdown('SIGINT'))
|
process.on('SIGINT', () => shutdown('SIGINT'))
|
||||||
|
|||||||
+104
-1
@@ -1,7 +1,8 @@
|
|||||||
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
// 冒烟测试 —— 验证服务器各接口与各平台功能
|
||||||
// 运行:node src/smoke-test.js
|
// 运行: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 http from 'node:http'
|
||||||
import { spawn } from 'node:child_process'
|
import { spawn } from 'node:child_process'
|
||||||
@@ -19,6 +20,25 @@ const get = (path) => new Promise((resolve, reject) => {
|
|||||||
}).on('error', 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) => {
|
const test = async(name, fn) => {
|
||||||
try {
|
try {
|
||||||
const result = await fn()
|
const result = await fn()
|
||||||
@@ -156,6 +176,89 @@ await test('热搜 kw', async() => {
|
|||||||
return r.data.list[0]
|
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 passed = results.filter(r => r.ok).length
|
||||||
const failed = results.filter(r => !r.ok)
|
const failed = results.filter(r => !r.ok)
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user