// 最先加载 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' }) }