refactor(server): 迁移到 Fastify 5 + 完善文档与路由参数适配

This commit is contained in:
snowy-yang committed 2026-09-09 21:56:06 +08:00
1 parent 59b2aae584
commit 2b4bb16e02
16 files changed
+615 -1249

No files matched your search

-3
View File
@@ -15,9 +15,6 @@ export class ApiError extends Error {
}
}
/** 参数校验失败(FastAPI 风格使用 422) */
export const invalidParam = (msg, detail) => new ApiError(422, msg, detail)
/** 资源不存在(未知路由 / 不支持的平台) */
export const notFound = (msg, detail) => new ApiError(404, msg, detail)
-53
View File
@@ -1,53 +0,0 @@
// 极简 HTTP 路由器 —— 路径参数(:name)、405 检测、携带路由元数据(供 OpenAPI 文档生成)
export class Router {
#routes = []
get(pattern, meta, handler) {
this.#add('GET', pattern, meta, handler)
return this
}
#add(method, pattern, meta, handler) {
const segments = pattern.replace(/^\/+|\/+$/g, '').split('/')
this.#routes.push({ method, pattern, segments, meta: meta || {}, handler })
}
/**
* 匹配路由
* @returns {null | { handler: Function, meta: object, params: Record<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
}
+25 -102
View File
@@ -1,78 +1,24 @@
// OpenAPI 3.1 文档生成 —— 从路由元数据构建 /openapi.json,供 Swagger UI(/docs)与 ReDoc(/redoc)使用
// OpenAPI 领域模型 —— 注册为 Fastify 共享 schema(fastify.addSchema)
// 路由的 response schema 通过 `名称#` 形式的 $ref 引用;
// @fastify/swagger 生成文档时会自动映射到 components/schemas/名称
// 注:additionalProperties: true 保证运行时序列化不剥离未声明字段
const json = (schema) => ({ 'application/json': { schema } })
const ref = (name) => ({ $ref: `#/components/schemas/${name}` })
const ref = (name) => ({ $ref: `${name}#` })
/** 200 响应体:统一信封 { code: 0, data } */
const okResponse = (dataSchema, description) => ({
description: description || '成功',
content: {
'application/json': {
schema: {
type: 'object',
properties: {
code: { type: 'integer', description: '0 表示成功', example: 0 },
data: dataSchema ? ref(dataSchema) : { description: '响应数据,结构由接口决定' },
},
required: ['code', 'data'],
},
},
},
})
const errorResponse = (description) => ({
description,
content: json(ref('ErrorEnvelope')),
})
const buildParameters = (meta) => {
const parameters = []
for (const [name, def] of Object.entries(meta.pathParams || {})) {
parameters.push({
name,
in: 'path',
required: true,
description: def.description,
schema: paramSchema(def),
example: def.example,
})
}
for (const [name, def] of Object.entries(meta.queryParams || {})) {
parameters.push({
name,
in: 'query',
required: Boolean(def.required),
description: def.description,
schema: paramSchema(def),
example: def.example,
})
}
return parameters
}
const paramSchema = (def) => {
const schema = { type: def.enum ? 'string' : (def.type || 'string') }
if (def.enum) schema.enum = def.enum
if (def.default !== undefined) schema.default = def.default
if (def.minimum !== undefined) schema.minimum = def.minimum
if (def.maximum !== undefined) schema.maximum = def.maximum
return schema
}
/** 领域模型(components/schemas),与 musicSdk/types.ts 的运行时数据形状对应 */
export const COMPONENT_SCHEMAS = {
ErrorEnvelope: {
type: 'object',
properties: {
code: { type: 'integer', description: '与 HTTP 状态码一致', example: 422 },
msg: { type: 'string', description: '错误描述' },
detail: { description: '可选的结构化错误详情' },
},
additionalProperties: true,
required: ['code', 'msg'],
},
QualityInfo: {
type: 'object',
properties: { size: { type: 'string', example: '9.8M' } },
additionalProperties: true,
},
SongType: {
type: 'object',
@@ -80,6 +26,7 @@ export const COMPONENT_SCHEMAS = {
type: { type: 'string', example: '320k', enum: ['128k', '320k', 'flac', 'flac24bit'] },
size: { type: 'string', example: '9.8M' },
},
additionalProperties: true,
},
SongInfo: {
type: 'object',
@@ -98,6 +45,7 @@ export const COMPONENT_SCHEMAS = {
_types: { type: 'object', additionalProperties: ref('QualityInfo') },
typeUrl: { type: 'object' },
},
additionalProperties: true,
},
SearchResult: {
type: 'object',
@@ -107,12 +55,14 @@ export const COMPONENT_SCHEMAS = {
page: { type: 'integer' },
limit: { type: 'integer' },
},
additionalProperties: true,
},
FindResult: {
type: 'object',
properties: {
list: { type: 'array', items: ref('SongInfo'), description: '按匹配度排序的歌曲列表' },
},
additionalProperties: true,
},
MusicUrlResult: {
type: 'object',
@@ -120,6 +70,7 @@ export const COMPONENT_SCHEMAS = {
type: { type: 'string', example: '320k' },
url: { type: 'string', description: '播放地址' },
},
additionalProperties: true,
},
LyricInfo: {
type: 'object',
@@ -129,11 +80,13 @@ export const COMPONENT_SCHEMAS = {
rlyric: { type: 'string', description: '罗马音歌词(部分平台)' },
lxlyric: { type: 'string', description: '逐字歌词(部分平台)' },
},
additionalProperties: true,
required: ['lyric', 'tlyric'],
},
PicResult: {
type: 'object',
properties: { url: { type: 'string', description: '封面地址' } },
additionalProperties: true,
},
BoardItem: {
type: 'object',
@@ -142,10 +95,12 @@ export const COMPONENT_SCHEMAS = {
name: { type: 'string' },
bangid: { type: 'string', description: '平台原始榜单 id' },
},
additionalProperties: true,
},
BoardListResult: {
type: 'object',
properties: { list: { type: 'array', items: ref('BoardItem') } },
additionalProperties: true,
},
SongItem: {
type: 'object',
@@ -161,6 +116,7 @@ export const COMPONENT_SCHEMAS = {
img: { type: 'string' },
types: { type: 'array', items: ref('SongType') },
},
additionalProperties: true,
},
BoardDetailResult: {
type: 'object',
@@ -171,6 +127,7 @@ export const COMPONENT_SCHEMAS = {
limit: { type: 'integer' },
source: { type: 'string' },
},
additionalProperties: true,
},
HotSearchResult: {
type: 'object',
@@ -178,6 +135,7 @@ export const COMPONENT_SCHEMAS = {
source: { type: 'string' },
list: { type: 'array', items: { type: 'string' } },
},
additionalProperties: true,
},
SongListSummary: {
type: 'object',
@@ -192,6 +150,7 @@ export const COMPONENT_SCHEMAS = {
desc: { type: 'string' },
source: { type: 'string' },
},
additionalProperties: true,
},
SongListResult: {
type: 'object',
@@ -202,6 +161,7 @@ export const COMPONENT_SCHEMAS = {
limit: { type: 'integer' },
source: { type: 'string' },
},
additionalProperties: true,
},
SourceInfo: {
type: 'object',
@@ -220,14 +180,17 @@ export const COMPONENT_SCHEMAS = {
hotSearch: { type: 'boolean' },
songList: { type: 'boolean' },
},
additionalProperties: true,
},
},
additionalProperties: true,
},
SourcesResult: {
type: 'object',
properties: {
platforms: { type: 'array', items: ref('SourceInfo') },
},
additionalProperties: true,
},
HealthResult: {
type: 'object',
@@ -236,46 +199,6 @@ export const COMPONENT_SCHEMAS = {
uptime: { type: 'integer', description: '运行秒数' },
platforms: { type: 'integer', description: '已接入平台数' },
},
additionalProperties: true,
},
}
/**
* 从路由元数据构建 OpenAPI 3.1 文档
* @param {import('../http/router.js').Router} router
*/
export const buildOpenApi = (router, { title, description, version }) => {
const paths = {}
for (const route of router.routes) {
if (route.meta.hide) continue
const item = paths[route.pattern] || (paths[route.pattern] = {})
const meta = route.meta
item[route.method.toLowerCase()] = {
summary: meta.summary || route.pattern,
...(meta.description ? { description: meta.description } : {}),
tags: meta.tags || ['其他'],
...(meta.deprecated ? { deprecated: true } : {}),
parameters: buildParameters(meta),
responses: meta.rawResponses || {
'200': okResponse(meta.dataSchema, meta.responseDescription),
'422': errorResponse('参数校验失败'),
...(meta.possible404 ? { '404': errorResponse('不支持的平台') } : {}),
...(meta.possible502 ? { '502': errorResponse('上游平台请求失败') } : {}),
},
}
}
return {
openapi: '3.1.0',
info: { title, description, version },
servers: [{ url: '/', description: '当前服务器' }],
tags: [
{ name: '搜索', description: '歌曲搜索与跨平台匹配' },
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
{ name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' },
{ name: '系统', description: '健康检查、平台信息、文档' },
],
paths,
components: { schemas: COMPONENT_SCHEMAS },
}
}
+6 -33
View File
@@ -1,44 +1,17 @@
// 文档页面 —— Swagger UI(/docs)与 ReDoc(/redoc),静态 HTML + CDN 资源
// ReDoc 文档页 —— Swagger UI 由 @fastify/swagger-ui 提供,此处仅保留 ReDoc 版本
const SWAGGER_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui.css'
const SWAGGER_BUNDLE_CDN = 'https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js'
const REDOC_CDN = 'https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js'
const htmlShell = (title, body) => `<!DOCTYPE html>
export const redocHtml = ({ title, specUrl = '/openapi.json' }) => `<!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}
<title>${title} (ReDoc)</title>
<style>body { margin: 0; padding: 0; }</style>
</head>
<body>
${body.content}
<redoc spec-url="${specUrl}" hide-download-button="true"></redoc>
<script src="${REDOC_CDN}"></script>
</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>`,
})
+286 -304
View File
@@ -1,9 +1,9 @@
// 路由表 —— 接口定义 + OpenAPI 元数据 + 处理函数
// 处理函数返回普通对象 → 200 { code: 0, data }
// 抛出 ApiError → 对应 HTTP 状态码;抛出其他错误 → 500
// 返回 { __raw: true, status, headers, body } → 原样输出(文档页/重定向用)
// 业务路由 —— Fastify 插件形式
// schema(querystring/params)负责参数校验并生成文档;schema.response 负责文档中的响应结构
// 处理函数返回普通对象 → 由服务器的 preSerialization 钩子包成 { code: 0, data }
// 抛出 ApiError → 由全局 setErrorHandler 转为对应 HTTP 状态码
import { invalidParam, notFound, upstream } from './http/api-error.js'
import { notFound, upstream } from './http/api-error.js'
const QUALITIES = ['128k', '320k', 'flac', 'flac24bit']
@@ -14,16 +14,37 @@ const unwrap = (result) => (
: result
)
const required = (query, names) => {
const missing = names.filter(n => !query[n])
if (missing.length) throw invalidParam(`缺少参数: ${missing.join(', ')}`, { missing })
}
const envelope = (dataRef) => ({
type: 'object',
properties: {
code: { type: 'integer', description: '0 表示成功', example: 0 },
data: dataRef ? { $ref: `${dataRef}#` } : {},
},
required: ['code', 'data'],
})
const toInt = (value, name, { min = 1, max = Infinity, def }) => {
const n = parseInt(value ?? def, 10)
if (Number.isNaN(n) || n < min || n > max) throw invalidParam(`参数 ${name} 需为 ${min}~${max} 的整数`)
return n
}
/** 组装路由的 schema.response:200 信封 + 通用错误码文档 */
const responses = (dataRef, { notFoundDoc = true, upstreamDoc = true } = {}) => ({
'200': envelope(dataRef),
'422': { $ref: 'ErrorEnvelope#' },
...(notFoundDoc ? { '404': { $ref: 'ErrorEnvelope#' } } : {}),
...(upstreamDoc ? { '502': { $ref: 'ErrorEnvelope#' } } : {}),
})
// ---- 常用 JSON Schema 片段 ----
const pSource = (capability) => ({
type: 'string',
description: `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`,
example: 'kw',
})
const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' }
const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' }
const pQuality = { type: 'string', enum: QUALITIES, default: '128k', description: '音质' }
const pId = { type: 'string', description: '平台歌曲 id', example: '28423286' }
const qs = (properties, required = []) => ({ type: 'object', properties, required })
const params = (properties, required) => qs(properties, required ?? Object.keys(properties))
const getSource = (musicSdk, name, capability) => {
const m = musicSdk[name]
@@ -31,55 +52,40 @@ const getSource = (musicSdk, name, capability) => {
return m
}
// 常用查询参数元数据(多接口复用)
const pSource = (capability, description) => ({
description: description || `平台 id(见 /sources)${capability ? `,需支持 ${capability} 能力` : ''}`,
example: 'kw',
})
const pPage = { type: 'integer', default: 1, minimum: 1, description: '页码' }
const pLimit = { type: 'integer', default: 20, minimum: 1, maximum: 100, description: '每页条数' }
const pQuality = {
enum: QUALITIES,
default: '128k',
description: '音质',
}
/**
* 注册全部业务路由
* @param {import('./http/router.js').Router} router
* @param {object} musicSdk 聚合 SDK
* @param {import('fastify').FastifyInstance} fastify
*/
export const registerRoutes = (router, musicSdk) => {
export const musicRoutes = async(fastify) => {
const musicSdk = fastify.musicSdk
// ---------- 搜索 ----------
router.get('/search', {
summary: '搜索歌曲',
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。',
tags: ['搜索'],
queryParams: {
name: { required: true, description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' },
singer: { description: '歌手名', example: '周杰伦' },
source: pSource('search'),
page: pPage,
limit: pLimit,
fastify.get('/search', {
schema: {
summary: '搜索歌曲',
description: 'source 省略时聚合全部平台(排除 xm)搜索,失败的平台会被跳过。',
tags: ['搜索'],
querystring: qs({
name: { type: 'string', description: '歌名(可附带歌手,空格分隔)', example: '稻香 周杰伦' },
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
source: pSource('search'),
page: pPage,
limit: pLimit,
}, ['name']),
response: responses('SearchResult', { notFoundDoc: false }),
},
dataSchema: 'SearchResult',
possible502: true,
}, async({ query }) => {
required(query, ['name'])
const name = query.name
const singer = query.singer
const page = toInt(query.page, 'page', { def: 1 })
const limit = toInt(query.limit, 'limit', { def: 20, max: 100 })
}, async(request) => {
const { name, singer, source, page, limit } = request.query
const doSearch = (id) => musicSdk[id].musicSearch
.search(`${name} ${singer || ''}`.trim(), page, limit)
.then(r => r.list)
if (query.source) {
getSource(musicSdk, query.source, 'musicSearch')
if (source) {
getSource(musicSdk, source, 'musicSearch')
try {
return { source: query.source, list: await doSearch(query.source), page, limit }
return { source, list: await doSearch(source), page, limit }
} catch (err) {
throw upstream(err.message)
}
@@ -93,40 +99,38 @@ export const registerRoutes = (router, musicSdk) => {
return { source: 'all', list: lists.flat(), page, limit }
})
router.get('/find', {
summary: '跨平台精确匹配歌曲',
description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。',
tags: ['搜索'],
queryParams: {
name: { required: true, description: '歌名', example: '稻香' },
singer: { description: '歌手名', example: '周杰伦' },
albumName: { description: '专辑名', example: '魔杰座' },
interval: { description: '时长(mm:ss),用于时长校验', example: '03:43' },
excludeSource: { description: '聚合结果中跳过该平台', example: 'kw' },
fastify.get('/find', {
schema: {
summary: '跨平台精确匹配歌曲',
description: '按歌手、歌名、专辑、时长综合排序,返回最匹配的歌曲列表。',
tags: ['搜索'],
querystring: qs({
name: { type: 'string', description: '歌名', example: '稻香' },
singer: { type: 'string', description: '歌手名', example: '周杰伦' },
albumName: { type: 'string', description: '专辑名', example: '魔杰座' },
interval: { type: 'string', description: '时长(mm:ss),用于时长校验', example: '03:43' },
excludeSource: { type: 'string', description: '聚合结果中跳过该平台', example: 'kw' },
}, ['name']),
response: responses('FindResult', { notFoundDoc: false }),
},
dataSchema: 'FindResult',
possible502: true,
}, async({ query }) => {
required(query, ['name'])
}, async(request) => {
const { name, singer, albumName, interval, excludeSource } = request.query
try {
return {
list: await musicSdk.findMusic({
name: query.name,
singer: query.singer,
albumName: query.albumName,
interval: query.interval,
source: query.excludeSource,
}),
list: await musicSdk.findMusic({ name, singer, albumName, interval, source: excludeSource }),
}
} catch (err) {
throw upstream(err.message)
}
})
// ---------- 歌曲资源(路径参数风格,含旧 query 风格别名) ----------
// ---------- 歌曲资源(路径参数风格 + 旧 query 风格别名) ----------
const urlHandler = ({ source, id, quality, albumId }) => {
if (!QUALITIES.includes(quality)) throw invalidParam(`不支持的音质: ${quality}`, { enum: QUALITIES })
const urlHandler = async(request) => {
const q = request.params.id
? { source: request.params.source, id: request.params.id, ...request.query }
: request.query
const { source, id, quality, albumId } = q
const m = getSource(musicSdk, source, 'getMusicUrl')
const songInfo = { songmid: id, hash: id, copyrightId: id, albumId: albumId || '', _types: {} }
let result
@@ -139,47 +143,44 @@ export const registerRoutes = (router, musicSdk) => {
if (!res?.url) throw upstream('获取播放地址失败')
return { type: quality, url: res.url }
}).catch(err => {
throw err instanceof Error && err.status ? err : upstream(err.message)
throw err.status ? err : upstream(err.message)
})
}
router.get('/url/:source/:id', {
summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
tags: ['歌曲资源'],
pathParams: {
source: pSource('url'),
id: { description: '平台歌曲 id', example: '28423286' },
fastify.get('/url/:source/:id', {
schema: {
summary: '获取播放地址',
description: 'id 为平台歌曲 id:kw/tx/wy/mg 用 songmid,kg 用 hash。',
tags: ['歌曲资源'],
params: params({
source: pSource('url'),
id: pId,
}),
querystring: qs({ quality: pQuality, albumId: { type: 'string', description: '专辑 id' } }),
response: responses('MusicUrlResult'),
},
queryParams: {
quality: pQuality,
albumId: { description: '专辑 id(部分平台获取封面时需要)' },
},
dataSchema: 'MusicUrlResult',
possible404: true,
possible502: true,
}, ({ params, query }) => urlHandler({
source: params.source, id: params.id, quality: query.quality || '128k', albumId: query.albumId,
}))
}, urlHandler)
router.get('/url', {
summary: '获取播放地址(旧版 query 风格)',
tags: ['歌曲资源'],
deprecated: true,
queryParams: {
source: { ...pSource('url'), required: true },
id: { required: true, description: '平台歌曲 id' },
quality: pQuality,
albumId: { description: '专辑 id' },
fastify.get('/url', {
schema: {
summary: '获取播放地址(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('url'),
id: pId,
quality: pQuality,
albumId: { type: 'string', description: '专辑 id' },
}, ['source', 'id']),
response: responses('MusicUrlResult'),
},
dataSchema: 'MusicUrlResult',
possible404: true,
possible502: true,
}, ({ query }) => urlHandler({
source: query.source, id: query.id, quality: query.quality || '128k', albumId: query.albumId,
}))
}, urlHandler)
const lyricHandler = ({ source, id, name, interval }) => {
const lyricHandler = async(request) => {
const q = request.params.id
? { source: request.params.source, id: request.params.id, ...request.query }
: request.query
const { source, id, name, interval } = q
const m = getSource(musicSdk, source, 'getLyric')
let result
try {
@@ -188,46 +189,43 @@ export const registerRoutes = (router, musicSdk) => {
throw upstream(err.message)
}
return result.then(res => (res || { lyric: '', tlyric: '' })).catch(err => {
throw err instanceof Error && err.status ? err : upstream(err.message)
throw err.status ? err : upstream(err.message)
})
}
router.get('/lyric/:source/:id', {
summary: '获取歌词',
tags: ['歌曲资源'],
pathParams: {
source: pSource('lyric'),
id: { description: '平台歌曲 id', example: '28423286' },
fastify.get('/lyric/:source/:id', {
schema: {
summary: '获取歌词',
tags: ['歌曲资源'],
params: params({ source: pSource('lyric'), id: pId }),
querystring: qs({
name: { type: 'string', description: '歌名(部分平台搜索歌词时需要)' },
interval: { type: 'string', description: '时长(mm:ss),部分平台搜索歌词时需要' },
}),
response: responses('LyricInfo'),
},
queryParams: {
name: { description: '歌名(部分平台搜索歌词时需要)' },
interval: { description: '时长(mm:ss),部分平台搜索歌词时需要' },
},
dataSchema: 'LyricInfo',
possible404: true,
possible502: true,
}, ({ params, query }) => lyricHandler({
source: params.source, id: params.id, name: query.name, interval: query.interval,
}))
}, lyricHandler)
router.get('/lyric', {
summary: '获取歌词(旧版 query 风格)',
tags: ['歌曲资源'],
deprecated: true,
queryParams: {
source: { ...pSource('lyric'), required: true },
id: { required: true, description: '平台歌曲 id' },
name: { description: '歌名' },
interval: { description: '时长(mm:ss)' },
fastify.get('/lyric', {
schema: {
summary: '获取歌词(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('lyric'),
id: pId,
name: { type: 'string', description: '歌名' },
interval: { type: 'string', description: '时长(mm:ss)' },
}, ['source', 'id']),
response: responses('LyricInfo'),
},
dataSchema: 'LyricInfo',
possible404: true,
possible502: true,
}, ({ query }) => lyricHandler({
source: query.source, id: query.id, name: query.name, interval: query.interval,
}))
}, lyricHandler)
const picHandler = ({ source, id, albumId }) => {
const picHandler = async(request) => {
const q = request.params.id
? { source: request.params.source, id: request.params.id, ...request.query }
: request.query
const { source, id, albumId } = q
const m = getSource(musicSdk, source, 'getPic')
let result
try {
@@ -236,205 +234,189 @@ export const registerRoutes = (router, musicSdk) => {
throw upstream(err.message)
}
return result.then(url => ({ url })).catch(err => {
throw err instanceof Error && err.status ? err : upstream(err.message)
throw err.status ? err : upstream(err.message)
})
}
router.get('/pic/:source/:id', {
summary: '获取封面',
tags: ['歌曲资源'],
pathParams: {
source: pSource('pic'),
id: { description: '平台歌曲 id', example: '28423286' },
fastify.get('/pic/:source/:id', {
schema: {
summary: '获取封面',
tags: ['歌曲资源'],
params: params({ source: pSource('pic'), id: pId }),
querystring: qs({ albumId: { type: 'string', description: '专辑 id(kg 平台需要)' } }),
response: responses('PicResult'),
},
queryParams: {
albumId: { description: '专辑 id(kg 平台需要)' },
},
dataSchema: 'PicResult',
possible404: true,
possible502: true,
}, ({ params, query }) => picHandler({
source: params.source, id: params.id, albumId: query.albumId,
}))
}, picHandler)
router.get('/pic', {
summary: '获取封面(旧版 query 风格)',
tags: ['歌曲资源'],
deprecated: true,
queryParams: {
source: { ...pSource('pic'), required: true },
id: { required: true, description: '平台歌曲 id' },
albumId: { description: '专辑 id' },
fastify.get('/pic', {
schema: {
summary: '获取封面(旧版 query 风格)',
deprecated: true,
tags: ['歌曲资源'],
querystring: qs({
source: pSource('pic'),
id: pId,
albumId: { type: 'string', description: '专辑 id' },
}, ['source', 'id']),
response: responses('PicResult'),
},
dataSchema: 'PicResult',
possible404: true,
possible502: true,
}, ({ query }) => picHandler({
source: query.source, id: query.id, albumId: query.albumId,
}))
}, picHandler)
// ---------- 榜单 ----------
const boardsHandler = (source) => {
const m = getSource(musicSdk, source, 'leaderboard')
fastify.get('/boards/:source', {
schema: {
summary: '获取榜单列表',
tags: ['榜单'],
params: params({ source: pSource('boards') }),
response: responses('BoardListResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.params.source, 'leaderboard')
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
}
router.get('/boards/:source', {
summary: '获取榜单列表',
tags: ['榜单'],
pathParams: { source: pSource('boards') },
dataSchema: 'BoardListResult',
possible404: true,
possible502: true,
}, ({ params }) => boardsHandler(params.source))
router.get('/boards', {
summary: '获取榜单列表(旧版 query 风格)',
tags: ['榜单'],
deprecated: true,
queryParams: { source: { ...pSource('boards'), required: true } },
dataSchema: 'BoardListResult',
possible404: true,
possible502: true,
}, ({ query }) => {
required(query, ['source'])
return boardsHandler(query.source)
})
const boardHandler = ({ source, id, page }) => {
fastify.get('/boards', {
schema: {
summary: '获取榜单列表(旧版 query 风格)',
deprecated: true,
tags: ['榜单'],
querystring: qs({ source: pSource('boards') }, ['source']),
response: responses('BoardListResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.query.source, 'leaderboard')
return m.leaderboard.getBoards().catch(err => { throw upstream(err.message) })
})
const boardHandler = (source, id, page) => {
const m = getSource(musicSdk, source, 'leaderboard')
return m.leaderboard.getList(id, page).catch(err => { throw upstream(err.message) })
// 榜单列表返回的 id 带平台前缀(如 kw__93),详情接口需要裸 bangid(93),这里做归一化
const boardId = String(id).replace(new RegExp(`^${source}__`), '')
return m.leaderboard.getList(boardId, page).catch(err => { throw upstream(err.message) })
}
router.get('/boards/:source/:boardId', {
summary: '获取榜单详情',
tags: ['榜单'],
pathParams: {
source: pSource('boards'),
boardId: { description: '榜单 id(来自榜单列表接口)', example: 'tx__26' },
fastify.get('/boards/:source/:boardId', {
schema: {
summary: '获取榜单详情',
tags: ['榜单'],
params: params({
source: pSource('boards'),
boardId: { type: 'string', description: '榜单 id,兼容榜单列表返回的带前缀 id(kw__93)与裸 bangid(93)', example: 'kw__93' },
}),
querystring: qs({ page: pPage }),
response: responses('BoardDetailResult'),
},
queryParams: { page: pPage },
dataSchema: 'BoardDetailResult',
possible404: true,
possible502: true,
}, ({ params, query }) => boardHandler({
source: params.source, id: params.boardId, page: toInt(query.page, 'page', { def: 1 }),
}))
}, async(request) => boardHandler(
request.params.source, request.params.boardId, request.query.page,
))
router.get('/board', {
summary: '获取榜单详情(旧版 query 风格)',
tags: ['榜单'],
deprecated: true,
queryParams: {
source: { ...pSource('boards'), required: true },
id: { required: true, description: '榜单 id' },
page: pPage,
fastify.get('/board', {
schema: {
summary: '获取榜单详情(旧版 query 风格)',
deprecated: true,
tags: ['榜单'],
querystring: qs({
source: pSource('boards'),
id: { type: 'string', description: '榜单 id' },
page: pPage,
}, ['source', 'id']),
response: responses('BoardDetailResult'),
},
dataSchema: 'BoardDetailResult',
possible404: true,
possible502: true,
}, ({ query }) => {
required(query, ['source', 'id'])
return boardHandler({
source: query.source, id: query.id, page: toInt(query.page, 'page', { def: 1 }),
})
})
}, async(request) => boardHandler(
request.query.source, request.query.id, request.query.page,
))
// ---------- 歌单 & 热搜 ----------
const hotSearchHandler = (source) => {
const m = getSource(musicSdk, source, 'hotSearch')
fastify.get('/hot-search/:source', {
schema: {
summary: '获取热搜词',
tags: ['歌单'],
params: params({ source: pSource('hotSearch') }),
response: responses('HotSearchResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.params.source, 'hotSearch')
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
}
router.get('/hot-search/:source', {
summary: '获取热搜词',
tags: ['歌单'],
pathParams: { source: pSource('hotSearch') },
dataSchema: 'HotSearchResult',
possible404: true,
possible502: true,
}, ({ params }) => hotSearchHandler(params.source))
router.get('/hotSearch', {
summary: '获取热搜词(旧版 query 风格)',
tags: ['歌单'],
deprecated: true,
queryParams: { source: { ...pSource('hotSearch'), required: true } },
dataSchema: 'HotSearchResult',
possible404: true,
possible502: true,
}, ({ query }) => {
required(query, ['source'])
return hotSearchHandler(query.source)
})
const songListHandler = ({ source, page, sortId, tagId }) => {
fastify.get('/hotSearch', {
schema: {
summary: '获取热搜词(旧版 query 风格)',
deprecated: true,
tags: ['歌单'],
querystring: qs({ source: pSource('hotSearch') }, ['source']),
response: responses('HotSearchResult'),
},
}, async(request) => {
const m = getSource(musicSdk, request.query.source, 'hotSearch')
return m.hotSearch.getList().catch(err => { throw upstream(err.message) })
})
const songListHandler = (source, page, sortId, tagId) => {
const m = getSource(musicSdk, source, 'songList')
return m.songList.getList(sortId ?? 3, tagId ?? 0, page).catch(err => { throw upstream(err.message) })
// sortId 各平台词汇不同(kw 'new'、tx 5、wy 'hot'…),省略时用平台自己声明的默认排序
return m.songList.getList(sortId ?? m.songList.sortList?.[0]?.id, tagId, page).catch(err => { throw upstream(err.message) })
}
router.get('/song-list/:source', {
summary: '获取歌单列表',
tags: ['歌单'],
pathParams: { source: pSource('songList') },
queryParams: {
page: pPage,
sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' },
tagId: { type: 'integer', default: 0, description: '标签 id,0 为全部' },
},
dataSchema: 'SongListResult',
possible404: true,
possible502: true,
}, ({ params, query }) => songListHandler({
source: params.source,
page: toInt(query.page, 'page', { def: 1 }),
sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }),
tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }),
}))
router.get('/songList', {
summary: '获取歌单列表(旧版 query 风格)',
tags: ['歌单'],
deprecated: true,
queryParams: {
source: { ...pSource('songList'), required: true },
page: pPage,
sortId: { type: 'integer', default: 3, description: '排序 id(平台相关)' },
tagId: { type: 'integer', default: 0, description: '标签 id' },
},
dataSchema: 'SongListResult',
possible404: true,
possible502: true,
}, ({ query }) => {
required(query, ['source'])
return songListHandler({
source: query.source,
page: toInt(query.page, 'page', { def: 1 }),
sortId: query.sortId === undefined ? undefined : toInt(query.sortId, 'sortId', { min: 0 }),
tagId: query.tagId === undefined ? undefined : toInt(query.tagId, 'tagId', { min: 0 }),
fastify.get('/song-list/:source', {
schema: {
summary: '获取歌单列表',
tags: ['歌单'],
params: params({ source: pSource('songList') }),
querystring: qs({
page: pPage,
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,见 /sources 对应平台文档;省略时用平台默认)' },
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串;0 为全部)' },
}),
response: responses('SongListResult'),
},
}, async(request) => {
const { page, sortId, tagId } = request.query
return songListHandler(request.params.source, page, sortId, tagId)
})
fastify.get('/songList', {
schema: {
summary: '获取歌单列表(旧版 query 风格)',
deprecated: true,
tags: ['歌单'],
querystring: qs({
source: pSource('songList'),
page: pPage,
sortId: { type: ['integer', 'string'], description: '排序 id(平台相关,省略时用平台默认)' },
tagId: { type: ['integer', 'string'], description: '标签 id(平台相关,kw 为 `173-digest` 形态字符串)' },
}, ['source']),
response: responses('SongListResult'),
},
}, async(request) => {
const { source, page, sortId, tagId } = request.query
return songListHandler(source, page, sortId, tagId)
})
// ---------- 系统 ----------
router.get('/health', {
summary: '健康检查',
tags: ['系统'],
dataSchema: 'HealthResult',
}, () => ({
fastify.get('/health', {
schema: {
summary: '健康检查',
tags: ['系统'],
response: responses('HealthResult', { notFoundDoc: false, upstreamDoc: false }),
},
}, async() => ({
status: 'ok',
uptime: Math.round(process.uptime()),
platforms: musicSdk.sources.length,
}))
router.get('/sources', {
summary: '获取支持的平台',
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。',
tags: ['系统'],
dataSchema: 'SourcesResult',
}, () => ({
fastify.get('/sources', {
schema: {
summary: '获取支持的平台',
description: '返回各平台的音质支持与能力(search/url/lyric/pic/boards/hotSearch/songList)。',
tags: ['系统'],
response: responses('SourcesResult', { notFoundDoc: false, upstreamDoc: false }),
},
}, async() => ({
platforms: musicSdk.sources.map(({ id, name }) => {
const m = musicSdk[id] || {}
const qualities = [...new Set(
+163 -148
View File
@@ -1,5 +1,5 @@
// HTTP 服务器入口 —— 多平台音乐聚合 API
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档
// HTTP 服务器入口(Fastify)—— 多平台音乐聚合 API
// 设计目标:标准 HTTP 语义 + FastAPI 风格的自动文档(校验与文档均由 JSON Schema 驱动)
//
// 规范路由(路径参数风格) 旧版路由(query 风格,保留兼容)
// GET /search (同左)
@@ -16,168 +16,183 @@
// 状态码语义:200 成功;422 参数校验失败;404 路由或平台不存在;405 方法不允许;502 上游平台失败;500 内部错误
// 响应信封:成功 { code: 0, data };失败 { code: <HTTP状态码>, msg, detail? }
import http from 'node:http'
import Fastify from 'fastify'
import cors from '@fastify/cors'
import swagger from '@fastify/swagger'
import swaggerUi from '@fastify/swagger-ui'
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { URL } from 'node:url'
import musicSdk from '../musicSdk/index.js'
import { Router } from './http/router.js'
import { ApiError } from './http/api-error.js'
import { registerRoutes } from './routes.js'
import { buildOpenApi } from './openapi/spec.js'
import { swaggerHtml, redocHtml } from './openapi/ui.js'
import { musicRoutes } from './routes.js'
import { COMPONENT_SCHEMAS } from './openapi/components.js'
import { redocHtml } from './openapi/ui.js'
const PORT = process.env.PORT || 3300
// 默认端口 16688:本机 TCP 动态端口范围是 1024~15000(出站连接会随机占用低端端口),避开该区间
const PORT = process.env.PORT || 16688
const API_TITLE = 'MusicServer 聚合音乐 API'
const API_DESCRIPTION = '多平台音乐聚合接口:搜索、播放地址、歌词、封面、榜单、歌单。' +
'支持 kw(酷我)/ kg(酷狗)/ tx(QQ音乐)/ wy(网易云)/ mg(咪咕)平台。'
const pkg = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '../package.json'), 'utf8'))
// ---- 路由装配 ----
const fastify = Fastify({
logger: false,
// schema 中的 example 等文档关键字仅供 OpenAPI 使用,关闭 Ajv 严格模式以免校验构建报错
ajv: { customOptions: { strict: false } },
})
fastify.decorate('musicSdk', musicSdk)
const router = new Router()
registerRoutes(router, musicSdk)
// ---- 共享 schema(领域模型:校验引用与 OpenAPI components 同源) ----
for (const [name, schema] of Object.entries(COMPONENT_SCHEMAS)) {
fastify.addSchema({ ...schema, $id: name })
}
let specCache = null
const openApiSpec = () => {
if (!specCache) specCache = buildOpenApi(router, {
title: API_TITLE,
description: API_DESCRIPTION,
version: pkg.version,
// ---- 全局中间行为 ----
// 收集全部路由模式(含 :param 参数段),供 not-found 处理器做 405 判定
const routePatterns = []
fastify.addHook('onRoute', (routeOptions) => {
if (!routeOptions.url || !routeOptions.method) return
// :param 段 → [^/]+;通配符 * → .*(如 @fastify/cors 注册的 catch-all 路由);其余按字面量转义
const source = routeOptions.url
.replace(/:[^/]+/g, '\u0000')
.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
.replace(/\u0000/g, '[^/]+')
.replace(/\\\*/g, '.*')
routePatterns.push({ method: routeOptions.method, regex: new RegExp(`^${source}$`) })
})
fastify.register(cors, { origin: '*' })
fastify.addHook('onRequest', async(_request, reply) => {
reply.header('Cache-Control', 'no-store')
})
fastify.addHook('onResponse', async(request, reply) => {
console.log(`[${new Date().toISOString()}] ${request.method} ${request.url} → ${reply.statusCode} (${Math.round(reply.elapsedTime)}ms)`)
})
// 成功响应统一包成 { code: 0, data };文档页/raw 路由与非 200 响应不包
fastify.addHook('preSerialization', async(request, reply, payload) => {
if (reply.statusCode !== 200) return payload
if (request.routeOptions.config?.raw) return payload
if (payload === null || typeof payload === 'object') return { code: 0, data: payload }
return payload
})
// ---- 错误处理 ----
fastify.setErrorHandler((err, request, reply) => {
if (err instanceof ApiError) {
const payload = { code: err.status, msg: err.message }
if (err.detail !== undefined) payload.detail = err.detail
return reply.code(err.status).send(payload)
}
// JSON Schema 校验失败(FastAPI 同款 422 语义)
if (err.validation) {
return reply.code(422).send({ code: 422, msg: err.message, detail: err.validation })
}
console.error(`[${new Date().toISOString()}] ${request.method} ${request.url} 内部错误:`, err)
return reply.code(500).send({ code: 500, msg: '服务器内部错误' })
})
fastify.setNotFoundHandler((request, reply) => {
// 路径存在但方法不允许 → 405 + Allow 头(Fastify 默认给 404,这里补齐标准语义)
// 用模式匹配而非 hasRoute:hasRoute 需要精确 URL,匹配不到 /url/:source/:id 这类参数路由
const pathname = new URL(request.url, 'http://localhost').pathname
const allowed = [...new Set(
routePatterns
.filter(r => r.regex.test(pathname) && r.method !== request.method)
// OPTIONS/HEAD 是辅助方法(CORS 预检由 @fastify/cors 的通配路由承接),不应计入 405 判定
.filter(r => r.method !== 'OPTIONS' && r.method !== 'HEAD')
.map(r => r.method),
)]
if (allowed.length) {
reply.code(405).header('Allow', allowed.join(', '))
return reply.send({ code: 405, msg: `方法 ${request.method} 不允许,请使用 ${allowed.join(', ')}` })
}
reply.code(404).send({ code: 404, msg: `接口不存在: ${request.url}` })
})
// ---- OpenAPI 文档 ----
fastify.register(swagger, {
// 组件按 addSchema 的 $id 命名(默认 def-1/def-2 不可读)
refResolver: {
buildLocalReference: (json, _baseUri, _fragment, i) => json.$id || `def-${i}`,
},
openapi: {
openapi: '3.1.0',
info: {
title: API_TITLE,
description: API_DESCRIPTION,
version: pkg.version,
},
servers: [{ url: '/', description: '当前服务器' }],
tags: [
{ name: '搜索', description: '歌曲搜索与跨平台匹配' },
{ name: '歌曲资源', description: '播放地址、歌词、封面' },
{ name: '榜单', description: '排行榜' },
{ name: '歌单', description: '歌单与热搜' },
{ name: '系统', description: '健康检查、平台信息、文档' },
],
},
})
fastify.register(swaggerUi, {
routePrefix: '/docs',
url: '/openapi.json',
})
fastify.get('/openapi.json', {
config: { raw: true },
schema: {
summary: 'OpenAPI 3.1 规范文档',
description: '机器可读的接口描述,可用于生成各语言客户端。',
tags: ['系统'],
},
}, async() => fastify.swagger())
fastify.get('/redoc', {
schema: {
summary: 'ReDoc 阅读版文档',
tags: ['系统'],
},
}, async(_request, reply) => reply.type('text/html; charset=utf-8').send(redocHtml({ title: API_TITLE })))
fastify.get('/', {
schema: {
summary: 'API 文档入口',
description: '302 重定向到 Swagger UI 文档页。',
tags: ['系统'],
},
}, async(_request, reply) => reply.redirect('/docs'))
// ---- 业务路由 ----
fastify.register(musicRoutes)
export const startServer = async(port = PORT) => {
await fastify.listen({ port, host: '0.0.0.0' })
console.log(`音乐服务器已启动: http://localhost:${port}`)
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
return fastify
}
const shutdown = (signal) => {
console.log(`\n收到 ${signal},正在关闭服务器...`)
fastify.close().then(() => {
console.log('服务器已关闭')
process.exit(0)
})
return specCache
}
router.get('/', {
summary: 'API 文档入口',
description: '302 重定向到 Swagger UI 文档页。',
tags: ['系统'],
rawResponses: { '302': { description: '重定向到 /docs' } },
}, () => ({ __raw: true, status: 302, headers: { Location: '/docs' }, body: '' }))
router.get('/docs', {
summary: 'Swagger UI 交互文档',
description: '基于 OpenAPI 规范的交互式文档,可直接在页面上调试接口。',
tags: ['系统'],
rawResponses: { '200': { description: 'Swagger UI 页面' } },
}, () => ({
__raw: true,
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
body: swaggerHtml({ title: API_TITLE }),
}))
router.get('/redoc', {
summary: 'ReDoc 阅读版文档',
tags: ['系统'],
rawResponses: { '200': { description: 'ReDoc 页面' } },
}, () => ({
__raw: true,
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
body: redocHtml({ title: API_TITLE }),
}))
router.get('/openapi.json', {
summary: 'OpenAPI 3.1 规范文档',
description: '机器可读的接口描述,可用于生成各语言客户端。',
tags: ['系统'],
rawResponses: { '200': { description: 'OpenAPI 规范(JSON)' } },
}, () => ({
__raw: true,
status: 200,
headers: { 'Content-Type': 'application/json; charset=utf-8' },
body: JSON.stringify(openApiSpec(), null, 2),
}))
// ---- HTTP 处理 ----
const sendJson = (res, status, payload) => {
if (res.writableEnded) return
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(payload))
}
const logRequest = (req, pathname, search, status, ms) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${pathname}${search} → ${status} (${ms}ms)`)
}
const handler = async(req, res) => {
const started = Date.now()
res.setHeader('Access-Control-Allow-Origin', '*')
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
res.setHeader('Cache-Control', 'no-store')
if (req.method === 'OPTIONS') {
res.writeHead(204)
res.end()
logRequest(req, req.url, '', 204, Date.now() - started)
return
}
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`)
const query = Object.fromEntries(url.searchParams.entries())
let status = 500
try {
const matched = router.match(url.pathname, req.method)
if (!matched) throw new ApiError(404, `接口不存在: ${url.pathname}`)
if (matched.allowed) {
res.setHeader('Allow', matched.allowed.join(', '))
throw new ApiError(405, `方法 ${req.method} 不允许,请使用 ${matched.allowed.join(', ')}`)
}
const out = await matched.handler({ req, res, params: matched.params, query })
if (out && out.__raw) {
status = out.status || 200
res.writeHead(status, out.headers)
res.end(out.body)
} else {
status = 200
sendJson(res, status, { code: 0, data: out })
}
} catch (err) {
if (err instanceof ApiError) {
status = err.status
const payload = { code: err.status, msg: err.message }
if (err.detail !== undefined) payload.detail = err.detail
sendJson(res, status, payload)
} else {
status = 500
console.error(`[${new Date().toISOString()}] ${req.method} ${url.pathname} 内部错误:`, err)
sendJson(res, status, { code: 500, msg: '服务器内部错误' })
}
}
logRequest(req, url.pathname, url.search, status, Date.now() - started)
}
export const startServer = (port = PORT) => {
const server = http.createServer(handler)
server.listen(port, () => {
console.log(`音乐服务器已启动: http://localhost:${port}`)
console.log(`接口文档: http://localhost:${port}/docs (Swagger UI)`)
console.log(`ReDoc 文档: http://localhost:${port}/redoc`)
console.log(`OpenAPI: http://localhost:${port}/openapi.json`)
})
const shutdown = (signal) => {
console.log(`\n收到 ${signal},正在关闭服务器...`)
server.close(() => {
console.log('服务器已关闭')
process.exit(0)
})
server.closeAllConnections?.()
setTimeout(() => process.exit(0), 3000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
return server
setTimeout(() => process.exit(0), 3000).unref()
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
// 直接运行时启动
if (process.argv[1] && (process.argv[1].endsWith('server.js') || process.argv[1].endsWith('src\\server.js'))) {
+51 -5
View File
@@ -1,13 +1,15 @@
// 冒烟测试 —— 验证服务器各接口与各平台功能
// 运行:node src/smoke-test.js
//
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词(kw)、封面、榜单、热搜、
// 标准 HTTP 语义(404/405/422/302)、文档(/docs /openapi.json /sources)
// 覆盖:搜索(各平台 + 聚合)、URL(kw/wy/tx)、歌词、封面、榜单详情、热搜、歌单、
// 跨平台匹配 /find、路径参数风格路由、标准 HTTP 语义(404/405/422/302)、文档页
import http from 'node:http'
import { spawn } from 'node:child_process'
const BASE = process.env.BASE_URL || 'http://localhost:3300'
// 本地默认 16688(避开 1024~15000 的动态端口冲突区),可用 PORT/BASE_URL 覆盖
const PORT = process.env.PORT || 16688
const BASE = process.env.BASE_URL || `http://localhost:${PORT}`
const results = []
const get = (path) => new Promise((resolve, reject) => {
@@ -69,6 +71,7 @@ const isServerRunning = () => new Promise(resolve => {
const startLocalServer = () => new Promise((resolve, reject) => {
const proc = spawn(process.execPath, ['src/server.js'], {
cwd: process.cwd(),
env: { ...process.env, PORT: String(PORT) },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
@@ -208,6 +211,42 @@ await test('榜单 路径风格 /boards/:source', async() => {
return `${r.data.list.length} 个榜单`
})
await test('封面 路径风格 /pic/kw/:id', async() => {
const r = await get('/pic/kw/28423286')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.url) throw new Error('无 url')
return r.data.url.slice(0, 60)
})
await test('榜单详情 路径风格 /boards/:source/:boardId', async() => {
// 用列表返回的带前缀 id(回归:详情接口应自动归一化为裸 bangid)
const r = await get('/boards/kw/kw__93?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 首`
})
await test('跨平台匹配 /find', async() => {
const r = await get(`/find?name=${encodeURIComponent('稻香')}&singer=${encodeURIComponent('周杰伦')}`)
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list[0].name} / ${r.data.list[0].singer}`
})
await test('歌单 路径风格 /song-list/:source', async() => {
const r = await get('/song-list/kg?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表')
return `${r.data.list.length} 个歌单`
})
await test('歌单 默认排序 /song-list/kw', async() => {
const r = await get('/song-list/kw?page=1')
if (r.code != 0) throw new Error(r.msg)
if (!r.data?.list?.length) throw new Error('空列表(sortId 默认值未适配平台)')
return `${r.data.list.length} 个歌单`
})
await test('参数校验 422(缺少 name)', async() => {
const r = await getRaw('/search')
if (r.status !== 422) throw new Error(`期望 422,实际 ${r.status}`)
@@ -228,13 +267,20 @@ await test('404 未知接口', async() => {
return JSON.parse(r.body).msg
})
await test('405 方法不允许', async() => {
await test('405 方法不允许(静态路由)', async() => {
const r = await postRaw('/search')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
if (!r.headers.allow) throw new Error('缺少 Allow 头')
return `Allow: ${r.headers.allow}`
})
await test('405 方法不允许(参数路由)', async() => {
const r = await postRaw('/url/kw/28423286')
if (r.status !== 405) throw new Error(`期望 405,实际 ${r.status}`)
if (!r.headers.allow || !r.headers.allow.includes('GET')) throw new Error(`Allow 头异常: ${r.headers.allow}`)
return `Allow: ${r.headers.allow}`
})
await test('根路径 302 → /docs', async() => {
const r = await getRaw('/')
if (r.status !== 302) throw new Error(`期望 302,实际 ${r.status}`)
@@ -255,7 +301,7 @@ await test('OpenAPI 规范 /openapi.json', async() => {
const spec = JSON.parse(r.body)
if (!spec.openapi?.startsWith('3.')) throw new Error(`openapi 版本异常: ${spec.openapi}`)
if (!spec.paths?.['/search']) throw new Error('缺少 /search 定义')
if (!spec.paths?.['/url/:source/:id']) throw new Error('缺少路径参数路由定义')
if (!spec.paths?.['/url/{source}/{id}']) throw new Error('缺少路径参数路由定义')
return `${Object.keys(spec.paths).length} 个路径`
})