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

+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'))) {