78 lines
3.2 KiB
TypeScript
78 lines
3.2 KiB
TypeScript
// 最先加载 server/.env(dotenv),确保 DATA_DIR / PORT / AI_* 在模块读取前就位
|
||
import './env.js'
|
||
|
||
import { pathToFileURL } from 'node:url'
|
||
import Fastify from 'fastify'
|
||
import cors from '@fastify/cors'
|
||
import swagger from '@fastify/swagger'
|
||
import swaggerUi from '@fastify/swagger-ui'
|
||
import { initializeData } from './data/seed.js'
|
||
import { registerRoutes, routeCount } from './routes.js'
|
||
import { errorHandler, notFoundHandler } from './errors.js'
|
||
|
||
/** 构建 Fastify 实例(不监听端口,供运行与 OpenAPI 导出共用) */
|
||
export async function buildServer() {
|
||
const app = Fastify({ logger: true, exposeHeadRoutes: false })
|
||
|
||
// 允许「Content-Type: application/json 但无 body」的请求(如 DELETE 常带该头但无体)。
|
||
// 默认 JSON 解析器遇到空 body 会抛 400「请求参数不合法」;这里把空 body 视为 undefined,避免误伤。
|
||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||
if (body === undefined || body === null || body === '') return done(null, undefined)
|
||
try {
|
||
done(null, JSON.parse(body as string))
|
||
} catch (err) {
|
||
done(err as Error, undefined)
|
||
}
|
||
})
|
||
|
||
// 允许浏览器跨域调用本地后端(GET/POST/PATCH/DELETE 均为现有接口用到的语义化方法)
|
||
await app.register(cors, {
|
||
origin: true,
|
||
methods: ['GET', 'HEAD', 'POST', 'PATCH', 'DELETE']
|
||
})
|
||
|
||
await app.register(swagger, {
|
||
openapi: {
|
||
info: {
|
||
title: '备考通 API',
|
||
description: '个人本地部署的公务员考试备考工具接口(语义化路径,统一错误格式)',
|
||
version: '1.0.0'
|
||
},
|
||
servers: [{ url: `http://localhost:${process.env.PORT ?? 3000}`, description: '本地服务' }],
|
||
tags: [
|
||
{ name: '数据中枢', description: '备考数据概览与学习趋势' },
|
||
{ name: '刷题', description: '模块组卷、作答与结果' },
|
||
{ name: '错题复习', description: '错题沉淀与间隔复习' },
|
||
{ name: '备考计划', description: '今日与本周任务' },
|
||
{ name: '模考分析', description: '行测模考成绩与分析' },
|
||
{ name: '要闻', description: '时政要闻列表与导入' },
|
||
{ name: '题库管理', description: '题目导入导出与删除' },
|
||
{ name: 'AI 讲解', description: 'AI 题目讲解(Token 仅在后端配置)' },
|
||
{ name: '个人档案', description: '学习档案' },
|
||
{ name: '设置', description: '偏好设置' }
|
||
]
|
||
}
|
||
})
|
||
await app.register(swaggerUi, { routePrefix: '/api/docs' })
|
||
|
||
app.setErrorHandler(errorHandler)
|
||
app.setNotFoundHandler(notFoundHandler)
|
||
|
||
// OpenAPI 文档端点(需求文档 2.2 约定的访问地址)
|
||
app.get('/api/openapi.json', async () => app.swagger())
|
||
|
||
app.get('/health', async () => ({ status: 'ok', service: 'gwy-server', routes: routeCount }))
|
||
|
||
await app.register(registerRoutes)
|
||
return app
|
||
}
|
||
|
||
const invokedDirectly =
|
||
process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href
|
||
|
||
if (invokedDirectly) {
|
||
const app = await buildServer()
|
||
await initializeData()
|
||
await app.listen({ port: Number(process.env.PORT ?? 3000), host: process.env.HOST ?? '0.0.0.0' })
|
||
}
|