import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' import { z } from 'zod' import { ErrorResponseSchema } from './schemas/api.js' import { MockExamSchema, NewsItemSchema, StudyPlanSchema } from './schemas/entities.js' import * as schemas from './schemas/api.js' import * as dashboard from './handlers/dashboard.js' import * as practice from './handlers/practice.js' import * as review from './handlers/review.js' import * as plans from './handlers/plans.js' import * as mock from './handlers/mock.js' import * as news from './handlers/news.js' import * as questions from './handlers/questions.js' import * as ai from './handlers/ai.js' import * as profile from './handlers/profile.js' type RouteDef = { method: 'GET' | 'POST' | 'PATCH' | 'DELETE' url: string summary: string tags: string[] params?: z.ZodType query?: z.ZodType body?: z.ZodType response: Record handler: (request: FastifyRequest, reply: FastifyReply) => unknown } /** * 全部业务路由的唯一登记表。 * - 请求/响应 Schema 以 Zod 定义(单一来源),注册时转换为 JSON Schema: * 请求侧用 io=input 交给 Ajv 校验,响应侧用 io=output 交给序列化与 OpenAPI 文档。 * - 每个接口自动附带 400 / 404 统一错误响应 Schema,保证“没有无 Schema 的接口”。 */ const routes: RouteDef[] = [ // 数据中枢 { method: 'GET', url: '/api/dashboard/overview', summary: '备考概览', tags: ['数据中枢'], response: { 200: schemas.DashboardOverviewResponseSchema }, handler: dashboard.overview }, { method: 'GET', url: '/api/dashboard/trends', summary: '学习趋势', tags: ['数据中枢'], query: schemas.DashboardTrendsQuerySchema, response: { 200: schemas.DashboardTrendsResponseSchema }, handler: dashboard.trends }, // 刷题 { method: 'GET', url: '/api/practice/modules', summary: '行测模块与题量', tags: ['刷题'], response: { 200: schemas.PracticeModulesResponseSchema }, handler: practice.modules }, { method: 'POST', url: '/api/practice/start', summary: '开始刷题(模块组卷)', tags: ['刷题'], body: schemas.PracticeStartBodySchema, response: { 200: schemas.PracticeSessionViewSchema }, handler: practice.start }, { method: 'GET', url: '/api/practice/:sessionId/question', summary: '获取当前题目', tags: ['刷题'], params: schemas.SessionIdParamsSchema, response: { 200: schemas.PracticeQuestionResponseSchema }, handler: practice.currentQuestion }, { method: 'POST', url: '/api/practice/:sessionId/answer', summary: '提交单题作答', tags: ['刷题'], params: schemas.SessionIdParamsSchema, body: schemas.PracticeAnswerBodySchema, response: { 200: schemas.PracticeAnswerResponseSchema }, handler: practice.answer }, { method: 'POST', url: '/api/practice/:sessionId/finish', summary: '结束会话并返回结果', tags: ['刷题'], params: schemas.SessionIdParamsSchema, response: { 200: schemas.PracticeFinishResponseSchema }, handler: practice.finish }, // 错题复习 { method: 'GET', url: '/api/review/list', summary: '错题列表', tags: ['错题复习'], query: schemas.ReviewListQuerySchema, response: { 200: schemas.ReviewListResponseSchema }, handler: review.list }, { method: 'POST', url: '/api/review/:id/submit', summary: '提交复习作答', tags: ['错题复习'], params: schemas.WrongQuestionIdParamsSchema, body: schemas.ReviewSubmitBodySchema, response: { 200: schemas.ReviewSubmitResponseSchema }, handler: review.submit }, { method: 'POST', url: '/api/review/:id/mark-mastered', summary: '标记已掌握', tags: ['错题复习'], params: schemas.WrongQuestionIdParamsSchema, response: { 200: schemas.ReviewMarkResponseSchema }, handler: review.markMastered }, // 备考计划 { method: 'GET', url: '/api/plans/today', summary: '今日任务', tags: ['备考计划'], query: schemas.PlansTodayQuerySchema, response: { 200: schemas.PlansTodayResponseSchema }, handler: plans.today }, { method: 'GET', url: '/api/plans/week', summary: '本周任务', tags: ['备考计划'], query: schemas.PlansWeekQuerySchema, response: { 200: schemas.PlansWeekResponseSchema }, handler: plans.week }, { method: 'POST', url: '/api/plans/tasks', summary: '创建计划任务', tags: ['备考计划'], body: schemas.PlanCreateBodySchema, response: { 200: StudyPlanSchema }, handler: plans.createTask }, { method: 'PATCH', url: '/api/plans/tasks/:id/toggle', summary: '切换任务完成状态', tags: ['备考计划'], params: schemas.PlanTaskIdParamsSchema, response: { 200: schemas.PlanToggleResponseSchema }, handler: plans.toggleTask }, // 模考分析 { method: 'GET', url: '/api/mock-exams/analysis', summary: '模考成绩分析', tags: ['模考分析'], response: { 200: schemas.MockExamAnalysisResponseSchema }, handler: mock.analysis }, { method: 'POST', url: '/api/mock-exams/record', summary: '录入模考成绩', tags: ['模考分析'], body: schemas.MockExamRecordBodySchema, response: { 200: MockExamSchema }, handler: mock.record }, // 要闻 { method: 'GET', url: '/api/news/list', summary: '要闻列表', tags: ['要闻'], query: schemas.NewsListQuerySchema, response: { 200: schemas.NewsListResponseSchema }, handler: news.list }, { method: 'GET', url: '/api/news/:id', summary: '要闻详情', tags: ['要闻'], params: schemas.NewsIdParamsSchema, response: { 200: NewsItemSchema }, handler: news.detail }, { method: 'POST', url: '/api/news/import/json', summary: 'JSON 导入要闻', tags: ['要闻'], body: schemas.NewsImportJsonBodySchema, response: { 200: schemas.ImportResultSchema }, handler: news.importJson }, { method: 'POST', url: '/api/news/import/rss', summary: 'RSS 导入入口', tags: ['要闻'], body: schemas.NewsImportRssBodySchema, response: { 200: schemas.ImportResultSchema }, handler: news.importRss }, { method: 'POST', url: '/api/news/import/api', summary: 'API 导入入口', tags: ['要闻'], body: schemas.NewsImportApiBodySchema, response: { 200: schemas.ImportResultSchema }, handler: news.importApi }, { method: 'POST', url: '/api/news/import/url', summary: 'URL 导入入口', tags: ['要闻'], body: schemas.NewsImportUrlBodySchema, response: { 200: schemas.ImportResultSchema }, handler: news.importUrl }, // 题库管理 { method: 'GET', url: '/api/questions/list', summary: '题目列表', tags: ['题库管理'], query: schemas.QuestionsListQuerySchema, response: { 200: schemas.QuestionsListResponseSchema }, handler: questions.list }, { method: 'POST', url: '/api/questions/import', summary: '题目 JSON 导入', tags: ['题库管理'], body: schemas.QuestionsImportBodySchema, response: { 200: schemas.ImportResultSchema }, handler: questions.importQuestions }, { method: 'GET', url: '/api/questions/export', summary: '导出当前题库', tags: ['题库管理'], response: { 200: schemas.QuestionsExportResponseSchema }, handler: questions.exportQuestions }, { method: 'DELETE', url: '/api/questions/:id', summary: '删除题目', tags: ['题库管理'], params: schemas.QuestionIdParamsSchema, response: { 200: schemas.QuestionsDeleteResponseSchema }, handler: questions.remove }, // AI 讲解 { method: 'POST', url: '/api/ai/explain-question', summary: 'AI 题目讲解', tags: ['AI 讲解'], body: schemas.AiExplainBodySchema, response: { 200: schemas.AiExplainResponseSchema }, handler: ai.explainQuestion }, // 个人档案 / 设置 { method: 'GET', url: '/api/profile', summary: '个人档案', tags: ['个人档案'], response: { 200: schemas.ProfileResponseSchema }, handler: profile.getProfile }, { method: 'PATCH', url: '/api/profile', summary: '修改个人档案', tags: ['个人档案'], body: schemas.ProfilePatchBodySchema, response: { 200: schemas.ProfileResponseSchema }, handler: profile.patchProfile }, { method: 'GET', url: '/api/settings', summary: '获取设置', tags: ['设置'], response: { 200: schemas.SettingsResponseSchema }, handler: profile.getSettings }, { method: 'PATCH', url: '/api/settings', summary: '修改设置', tags: ['设置'], body: schemas.SettingsPatchBodySchema, response: { 200: schemas.SettingsResponseSchema }, handler: profile.patchSettings } ] const toJsonSchema = (schema: z.ZodType | undefined, io: 'input' | 'output') => { if (!schema) return undefined return z.toJSONSchema(schema, { io, target: 'draft-7', unrepresentable: 'any' }) } const errorJsonSchema = z.toJSONSchema(ErrorResponseSchema, { io: 'output', target: 'draft-7', unrepresentable: 'any' }) export async function registerRoutes(app: FastifyInstance) { for (const route of routes) { app.route({ method: route.method, url: route.url, schema: { summary: route.summary, tags: route.tags, // 仅在确有 schema 时声明 params/querystring:若声明为空对象,@fastify/swagger // 会把 `{ type: 'object' }` 的 "type" 关键字误当作参数名,给每个路由生成虚假参数。 ...(route.params ? { params: toJsonSchema(route.params, 'input') } : {}), ...(route.query ? { querystring: toJsonSchema(route.query, 'input') } : {}), ...(route.method === 'GET' || route.method === 'DELETE' || !route.body ? {} : { body: toJsonSchema(route.body, 'input') }), response: Object.fromEntries( Object.entries({ 200: toJsonSchema(route.response[200], 'output'), 400: route.body || route.query || route.params ? errorJsonSchema : undefined, 404: errorJsonSchema, 500: errorJsonSchema }).filter(([, value]) => value !== undefined) ) }, handler: route.handler }) } } /** 已登记接口数量(供导出脚本与检查使用) */ export const routeCount = routes.length