274 lines
10 KiB
TypeScript
274 lines
10 KiB
TypeScript
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<string, z.ZodType>
|
||
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
|