feat(server): 标准 HTTP 服务器重构 + FastAPI 风格自动文档
This commit is contained in:
1 parent
1dfb7a59ca
commit
59b2aae584
9 files changed
+1236
-254
No files matched your search
+134
-253
@@ -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: <HTTP状态码>, 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'))
|
||||
|
||||
Reference in new issue
Block a user