From 4b2c80bef133c9f3e481d6cf33334c1ca984f233 Mon Sep 17 00:00:00 2001 From: liyy <18435186204@163.com> Date: Mon, 31 Aug 2026 16:48:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=90=8E=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/checks/task-03.md | 60 ++++++ pnpm-lock.yaml | 259 ++++++++++++++++++++++++-- server/package.json | 20 +- server/src/errors.ts | 110 +++++++++++ server/src/handlers/ai.ts | 28 +++ server/src/handlers/dashboard.ts | 79 ++++++++ server/src/handlers/mock.ts | 60 ++++++ server/src/handlers/news.ts | 75 ++++++++ server/src/handlers/plans.ts | 62 +++++++ server/src/handlers/practice.ts | 104 +++++++++++ server/src/handlers/profile.ts | 34 ++++ server/src/handlers/questions.ts | 50 +++++ server/src/handlers/review.ts | 66 +++++++ server/src/routes.ts | 273 +++++++++++++++++++++++++++ server/src/schemas/api.ts | 305 +++++++++++++++++++++++++++++++ server/src/schemas/entities.ts | 122 +++++++++++++ server/src/server.ts | 60 +++++- server/src/utils/dates.ts | 26 +++ server/src/utils/ids.ts | 6 + 19 files changed, 1771 insertions(+), 28 deletions(-) create mode 100644 docs/checks/task-03.md create mode 100644 server/src/errors.ts create mode 100644 server/src/handlers/ai.ts create mode 100644 server/src/handlers/dashboard.ts create mode 100644 server/src/handlers/mock.ts create mode 100644 server/src/handlers/news.ts create mode 100644 server/src/handlers/plans.ts create mode 100644 server/src/handlers/practice.ts create mode 100644 server/src/handlers/profile.ts create mode 100644 server/src/handlers/questions.ts create mode 100644 server/src/handlers/review.ts create mode 100644 server/src/routes.ts create mode 100644 server/src/schemas/api.ts create mode 100644 server/src/schemas/entities.ts create mode 100644 server/src/utils/dates.ts create mode 100644 server/src/utils/ids.ts diff --git a/docs/checks/task-03.md b/docs/checks/task-03.md new file mode 100644 index 0000000..0e16d15 --- /dev/null +++ b/docs/checks/task-03.md @@ -0,0 +1,60 @@ +# 任务 03 检查记录:接口层(路由、校验、错误处理、OpenAPI) + +日期:2026-08-31 + +## 交付内容 + +- `server/src/schemas/entities.ts`:全部领域实体的 Zod Schema(题目、要闻、计划、模考、会话、错题等)。 +- `server/src/schemas/api.ts`:34 个接口的请求/响应 Zod Schema(路由共 31 个路径,34 个操作)。 +- `server/src/errors.ts`:`ApiError` 业务异常 + 统一错误响应格式 `{ error: { code, message, details } }`;AJV 校验信息聚合翻译为中文(如"缺少必填字段 module"、"取值不符合预设选项")。 +- `server/src/handlers/`:dashboard、practice、review、plans、mock、news、questions、ai、profile 九个域的处理器。 +- `server/src/routes.ts`:路由集中注册,Zod → JSON Schema(`z.toJSONSchema`,draft-7)转换,含 tags/summary/response。 +- `server/src/server.ts`:`buildServer` 拆分(可测试/可导出),注册 `@fastify/swagger` + `@fastify/swagger-ui`,并挂载运行时端点 `GET /api/openapi.json`(返回 `app.swagger()`)。 + +## 命令检查 + +- `pnpm --filter @gwy/server typecheck`:通过。 +- `GET /api/openapi.json`:200,OpenAPI 3.0.3,登记接口 31 个、文档路径 31 个,全部路由含请求/响应 Schema。 +- 服务启动:无 FSTWRN001 等 Schema 警告(grep 计数为 0)。 + +## 接口实测(curl) + +| 接口 | 结果 | +|---|---| +| `GET /health` | 200,`{status:"ok",routes:31}` | +| `GET /api/openapi.json` | 200,约 81 KB | +| `GET /api/docs`(Swagger UI) | 200 | +| `GET /api/dashboard/overview` | 200,学习天数/正确率/待复习等字段齐全 | +| `GET /api/dashboard/trends?days=7` | 200,返回 7 天趋势数组 | +| `GET /api/practice/modules` | 200,五大模块题量 | +| `POST /api/practice/start`(正确参数) | 200,返回 sessionId 并持久化到 `data/practice-sessions.json` | +| `GET /api/practice/{sessionId}/question` | 200,不含答案与解析 | +| `POST /api/practice/{sessionId}/answer` | 200,`{accepted:true,answeredCount:0}`(记录落库留给任务08) | +| `POST /api/practice/{sessionId}/finish`(无请求体) | 200,返回结果骨架(真实计算留给任务08) | +| `GET /api/questions/list?module=言语理解` | 200,分页结构正确 | +| `GET /api/questions/export` | 200,导出 JSON | +| `GET /api/news/list`、`GET /api/news/{id}` | 200 | +| `GET /api/review/list`、`GET /api/plans/today`、`GET /api/plans/week` | 200 | +| `GET /api/mock-exams/analysis` | 200,模块均分数组齐全 | +| `GET /api/profile`、`GET /api/settings` | 200 | +| `POST /api/practice/start` 缺字段 | 400 `VALIDATION_ERROR`,details 中文提示"缺少必填字段 module" | +| `POST /api/practice/start` duration=7 | 400,聚合为一条"取值不符合预设选项" | +| `GET /api/questions/list?module=xingce` | 400,"取值必须是允许的枚举值之一" | +| 未注册路径 | 404 `NOT_FOUND`,`接口不存在:GET ...` | + +## 过程中发现并修复的问题 + +1. `response` 对象中存在 `400: undefined` 键导致 Fastify 遍历报错 → 改为过滤后仅保留已定义状态码。 +2. 无 body 的 POST 路由(如 finish)曾被占位空对象 Schema 误拒空请求体 → 无 body Schema 时直接省略 `body` 键。 +3. Zod union 字面量(duration 5|10|15)在 AJV anyOf 校验下报 4 条冗余英文错误 → 错误处理器按路径聚合并翻译为单条中文提示。 +4. `practice/start` 未持久化会话导致后续接口 404 → 补充 `updateData` 写入会话(组卷题量、作答记录、结果计算仍为任务08 范围)。 + +## 浏览器检查 + +任务 03 仅涉及后端接口层,未新增前端页面。Swagger UI(`http://127.0.0.1:3000/api/docs`)HTTP 200 可访问,未做前端回归(前端对接在任务04+)。 + +## 当前结论 + +接口层完成:31 个路径全部注册并带完整 Schema;统一错误格式(400/404/500 均验证);OpenAPI 文档由运行时端点 `GET /api/openapi.json` 提供(不再导出静态文件);核心业务链路(start → question → answer → finish)跑通且会话已持久化。剩余 TODO 已标注在 handlers 内,归属任务08(刷题业务规则)。 + +> 微调(2026-08-31):改为「不落盘、运行时提供」——删除 `server/scripts/export-openapi.ts`、`server/openapi.json` 与 `openapi:export` 脚本,前端直接消费 `http://localhost:3000/api/openapi.json`。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cd9a1e..fdceee1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,19 +12,19 @@ importers: dependencies: '@vitejs/plugin-vue': specifier: latest - version: 6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@5.9.3)) + version: 6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) pinia: specifier: latest version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) vite: specifier: latest - version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + version: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0) vue: specifier: latest version: 3.5.42(typescript@5.9.3) vue-router: specifier: latest - version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@5.9.3)) + version: 5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)) devDependencies: typescript: specifier: ^5.7.3 @@ -38,19 +38,25 @@ importers: '@fastify/cors': specifier: latest version: 11.3.0 + '@fastify/swagger': + specifier: ^9.8.1 + version: 9.8.1 + '@fastify/swagger-ui': + specifier: ^6.1.1 + version: 6.1.1 fastify: specifier: latest version: 5.12.1 zod: specifier: latest - version: 4.4.3 + version: 4.5.4 devDependencies: '@types/node': specifier: latest version: 26.4.0 tsx: specifier: latest - version: 4.23.12 + version: 4.23.13 typescript: specifier: latest version: 7.0.2 @@ -230,6 +236,9 @@ packages: cpu: [x64] os: [win32] + '@fastify/accept-negotiator@2.1.0': + resolution: {integrity: sha512-F3EVbzWt+xcnVaOHmWyIlpuFtbxOln7HDZQsh09MtMmMm/CipMayNt8hnIL8VQi54u2ZociDbf+iluGYkf7B1A==} + '@fastify/ajv-compiler@4.0.6': resolution: {integrity: sha512-NtuzM0SfaMJbGlnjr9LWQUN5LzgSrbB8tf/wRZNas+4E1O/Nmzl53e7ruT61HDZyRCJGC6FxIogmNZO1c5ETBA==} @@ -251,6 +260,18 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/send@4.1.1': + resolution: {integrity: sha512-BYo+EiaKwlxH+WetGk6hAs1d39iP0y1gqB8lGF/qwkJ9ZZ/cBY1vx5NvExb9Sc3yRMFjD5X4Eyh4e4+TzRkzdw==} + + '@fastify/static@10.1.3': + resolution: {integrity: sha512-W6jqajYS974XjPjB5hQWoxPM8NKM4+p8YmQT6G5IbCa4uhdWSVadZUv75siy1wEA/3ty8RYdpBydfWeu9AqAqQ==} + + '@fastify/swagger-ui@6.1.1': + resolution: {integrity: sha512-RKCLSHASlzS2JZvHWn14NmEpHyl0yNosGvqzhUumm/LGPG6RWQBf4oscTFt83QDvc5O5Tol3Beup8inAl/k4EA==} + + '@fastify/swagger@9.8.1': + resolution: {integrity: sha512-VpHMnqZTY8iBZYJE8WWkbKPrXIYWy2rDfIf5qLr6DzZSpQYZ+KxQVcJFiq/AMlvNwI4gCBd66++iUlxXXGT0IQ==} + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -267,6 +288,10 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@lukeed/ms@2.0.2': + resolution: {integrity: sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA==} + engines: {node: '>=8'} + '@oxc-project/types@0.147.0': resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} @@ -596,9 +621,17 @@ packages: avvio@9.3.0: resolution: {integrity: sha512-g2tQ7LE7oOSqDfwEm3M+ZCMTJc7KiZCdJ4UwyZJb5ckTKyYu50OYmvv0mCFXPuYXoM4zkSt8zM9XQ9KCvxA74A==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + brace-expansion@5.0.9: + resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + engines: {node: 20 || >=22} + chokidar@5.0.0: resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} engines: {node: '>= 20.19.0'} @@ -609,6 +642,10 @@ packages: confbox@0.2.4: resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} + content-disposition@2.0.1: + resolution: {integrity: sha512-e+H0ZXHSWYrENhQzw1LPuP4oF5MzVKmDU6d3hxlvaPEYLLg62MxtQNPRx4SYSuYJSBUgnQIG4HIN2tEtNv7Dog==} + engines: {node: '>=18'} + cookie@1.1.1: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} @@ -616,6 +653,19 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -633,6 +683,9 @@ packages: engines: {node: '>=18'} hasBin: true + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -684,9 +737,20 @@ packages: engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] + glob@13.0.6: + resolution: {integrity: sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==} + engines: {node: 18 || 20 || >=22} + hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ipaddr.js@2.5.0: resolution: {integrity: sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==} engines: {node: '>= 10'} @@ -694,6 +758,10 @@ packages: json-schema-ref-resolver@3.0.0: resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==} + json-schema-resolver@3.0.0: + resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==} + engines: {node: '>=20'} + json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} @@ -778,6 +846,10 @@ packages: resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} engines: {node: '>=14'} + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string-ast@1.0.3: resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} engines: {node: '>=20.19.0'} @@ -785,9 +857,25 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + mime@3.0.0: + resolution: {integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==} + engines: {node: '>=10.0.0'} + hasBin: true + + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} @@ -803,9 +891,16 @@ packages: resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} engines: {node: '>=14.0.0'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-scurry@2.0.2: + resolution: {integrity: sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==} + engines: {node: 18 || 20 || >=22} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -914,6 +1009,9 @@ packages: set-cookie-parser@2.7.2: resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} @@ -925,6 +1023,10 @@ packages: resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} engines: {node: '>= 10.x'} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -937,8 +1039,12 @@ packages: resolution: {integrity: sha512-m1TdR/rvT7kgGJZhspNtXdsdYk0fddFpJJFlG5s+UkPFo6lkLoZ3YLOaovPYjq1R75NP5JfeTlSHaOsE09peCg==} engines: {node: '>=20'} - tsx@4.23.12: - resolution: {integrity: sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + tsx@4.23.13: + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==} engines: {node: '>=18.0.0'} hasBin: true @@ -1076,8 +1182,13 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - zod@4.4.3: - resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} snapshots: @@ -1172,6 +1283,8 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true + '@fastify/accept-negotiator@2.1.0': {} + '@fastify/ajv-compiler@4.0.6': dependencies: ajv: 8.20.0 @@ -1200,6 +1313,42 @@ snapshots: '@fastify/forwarded': 3.0.2 ipaddr.js: 2.5.0 + '@fastify/send@4.1.1': + dependencies: + '@lukeed/ms': 2.0.2 + escape-html: 1.0.3 + fast-decode-uri-component: 1.0.1 + http-errors: 2.0.1 + mime: 3.0.0 + + '@fastify/static@10.1.3': + dependencies: + '@fastify/accept-negotiator': 2.1.0 + '@fastify/error': 4.2.0 + '@fastify/send': 4.1.1 + content-disposition: 2.0.1 + fastify-plugin: 6.0.0 + fastq: 1.20.1 + glob: 13.0.6 + + '@fastify/swagger-ui@6.1.1': + dependencies: + '@fastify/static': 10.1.3 + fastify-plugin: 6.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + + '@fastify/swagger@9.8.1': + dependencies: + fastify-plugin: 6.0.0 + json-schema-resolver: 3.0.0 + openapi-types: 12.1.3 + rfdc: 1.4.1 + yaml: 2.9.0 + transitivePeerDependencies: + - supports-color + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -1219,6 +1368,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@lukeed/ms@2.0.2': {} + '@oxc-project/types@0.147.0': {} '@pinojs/redact@0.4.0': {} @@ -1334,10 +1485,10 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@5.9.3))': + '@vitejs/plugin-vue@6.0.8(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3))': dependencies: '@rolldown/pluginutils': 1.0.1 - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0) vue: 3.5.42(typescript@5.9.3) '@volar/language-core@2.4.28': @@ -1474,8 +1625,14 @@ snapshots: '@fastify/error': 4.2.0 fastq: 1.20.1 + balanced-match@4.0.4: {} + birpc@2.9.0: {} + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + chokidar@5.0.0: dependencies: readdirp: 5.1.1 @@ -1484,10 +1641,18 @@ snapshots: confbox@0.2.4: {} + content-disposition@2.0.1: {} + cookie@1.1.1: {} csstype@3.2.3: {} + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -1523,6 +1688,8 @@ snapshots: '@esbuild/win32-ia32': 0.28.2 '@esbuild/win32-x64': 0.28.2 + escape-html@1.0.3: {} + estree-walker@2.0.2: {} exsolve@1.1.1: {} @@ -1585,14 +1752,38 @@ snapshots: fsevents@2.3.3: optional: true + glob@13.0.6: + dependencies: + minimatch: 10.2.6 + minipass: 7.1.3 + path-scurry: 2.0.2 + hookable@5.5.3: {} + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + inherits@2.0.4: {} + ipaddr.js@2.5.0: {} json-schema-ref-resolver@3.0.0: dependencies: dequal: 2.0.3 + json-schema-resolver@3.0.0: + dependencies: + debug: 4.4.3 + fast-uri: 3.1.6 + rfdc: 1.4.1 + transitivePeerDependencies: + - supports-color + json-schema-traverse@1.0.0: {} light-my-request@6.6.0: @@ -1656,6 +1847,8 @@ snapshots: pkg-types: 2.3.1 quansync: 0.2.11 + lru-cache@11.5.2: {} + magic-string-ast@1.0.3: dependencies: magic-string: 0.30.21 @@ -1664,6 +1857,14 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + mime@3.0.0: {} + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minipass@7.1.3: {} + mlly@1.8.2: dependencies: acorn: 8.18.0 @@ -1671,6 +1872,8 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 + ms@2.1.3: {} + muggle-string@0.4.1: {} nanoid@3.3.18: {} @@ -1679,8 +1882,15 @@ snapshots: on-exit-leak-free@2.1.2: {} + openapi-types@12.1.3: {} + path-browserify@1.0.1: {} + path-scurry@2.0.2: + dependencies: + lru-cache: 11.5.2 + minipass: 7.1.3 + pathe@2.0.3: {} perfect-debounce@2.1.0: {} @@ -1792,6 +2002,8 @@ snapshots: set-cookie-parser@2.7.2: {} + setprototypeof@1.2.0: {} + sonic-boom@4.2.1: dependencies: atomic-sleep: 1.0.0 @@ -1800,6 +2012,8 @@ snapshots: split2@4.2.0: {} + statuses@2.0.2: {} + thread-stream@4.2.0: dependencies: real-require: 1.0.0 @@ -1811,7 +2025,9 @@ snapshots: toad-cache@3.7.4: {} - tsx@4.23.12: + toidentifier@1.0.1: {} + + tsx@4.23.13: dependencies: esbuild: 0.28.2 optionalDependencies: @@ -1851,7 +2067,7 @@ snapshots: pathe: 2.0.3 picomatch: 4.0.7 - unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)): + unplugin@3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0)): dependencies: '@jridgewell/remapping': 2.3.5 picomatch: 4.0.7 @@ -1859,9 +2075,9 @@ snapshots: optionalDependencies: esbuild: 0.28.2 rolldown: 1.2.6 - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0) - vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12): + vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0): dependencies: lightningcss: 1.33.0 picomatch: 4.0.7 @@ -1872,11 +2088,12 @@ snapshots: '@types/node': 26.4.0 esbuild: 0.28.2 fsevents: 2.3.3 - tsx: 4.23.12 + tsx: 4.23.13 + yaml: 2.9.0 vscode-uri@3.2.0: {} - vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12))(vue@3.5.42(typescript@5.9.3)): + vue-router@5.3.0(@vue/compiler-sfc@3.5.42)(esbuild@0.28.2)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)))(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0))(vue@3.5.42(typescript@5.9.3)): dependencies: '@vue-macros/common': 3.1.4(vue@3.5.42(typescript@5.9.3)) '@vue/devtools-api': 8.2.1 @@ -1892,13 +2109,13 @@ snapshots: picomatch: 4.0.7 scule: 1.3.0 tinyglobby: 0.2.17 - unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12)) + unplugin: 3.3.0(esbuild@0.28.2)(rolldown@1.2.6)(vite@8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0)) unplugin-utils: 0.3.2 vue: 3.5.42(typescript@5.9.3) optionalDependencies: '@vue/compiler-sfc': 3.5.42 pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.42(typescript@5.9.3)) - vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.12) + vite: 8.2.2(@types/node@26.4.0)(esbuild@0.28.2)(tsx@4.23.13)(yaml@2.9.0) transitivePeerDependencies: - '@farmfe/core' - '@rspack/core' @@ -1927,4 +2144,6 @@ snapshots: webpack-virtual-modules@0.6.2: {} - zod@4.4.3: {} + yaml@2.9.0: {} + + zod@4.5.4: {} diff --git a/server/package.json b/server/package.json index 2778a12..74a3c5a 100644 --- a/server/package.json +++ b/server/package.json @@ -2,7 +2,21 @@ "name": "@gwy/server", "private": true, "type": "module", - "scripts": { "dev": "tsx watch src/server.ts", "build": "tsc", "typecheck": "tsc --noEmit" }, - "dependencies": { "@fastify/cors": "latest", "fastify": "latest", "zod": "latest" }, - "devDependencies": { "tsx": "latest", "typescript": "latest", "@types/node": "latest" } + "scripts": { + "dev": "tsx watch src/server.ts", + "build": "tsc", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@fastify/cors": "latest", + "@fastify/swagger": "^9.8.1", + "@fastify/swagger-ui": "^6.1.1", + "fastify": "latest", + "zod": "latest" + }, + "devDependencies": { + "@types/node": "latest", + "tsx": "latest", + "typescript": "latest" + } } diff --git a/server/src/errors.ts b/server/src/errors.ts new file mode 100644 index 0000000..649e6c6 --- /dev/null +++ b/server/src/errors.ts @@ -0,0 +1,110 @@ +import type { FastifyError, FastifyReply, FastifyRequest } from 'fastify' + +export type ErrorCode = + | 'VALIDATION_ERROR' + | 'NOT_FOUND' + | 'CONFLICT' + | 'AI_NOT_CONFIGURED' + | 'INTERNAL_ERROR' + +/** 业务错误:handlers / services 抛出,统一转换为约定的错误响应格式 */ +export class ApiError extends Error { + constructor( + readonly statusCode: number, + readonly code: ErrorCode, + message: string, + readonly details: { path: string; message: string }[] = [] + ) { + super(message) + this.name = 'ApiError' + } + + static notFound(message: string, details?: { path: string; message: string }[]) { + return new ApiError(404, 'NOT_FOUND', message, details) + } + + static aiNotConfigured(message: string) { + return new ApiError(503, 'AI_NOT_CONFIGURED', message) + } +} + +function sendError(reply: FastifyReply, statusCode: number, code: ErrorCode, message: string, details: { path: string; message: string }[] = []) { + return reply.status(statusCode).send({ error: { code, message, details } }) +} + +/** 将 AJV 校验信息翻译为可读中文 */ +function translateAjvMessage(message: string): string { + if (/must have required property/.test(message)) { + const prop = message.match(/'([^']+)'/)?.[1] ?? '' + return `缺少必填字段 ${prop}` + } + if (message === 'must be equal to one of the allowed values') return '取值必须是允许的枚举值之一' + if (message === 'must be string') return '类型应为字符串' + if (message === 'must be number') return '类型应为数字' + if (message === 'must be integer') return '应为整数' + if (message === 'must be boolean') return '应为布尔值' + if (message === 'must be array') return '应为数组' + if (message === 'must be object') return '应为对象' + if (message === 'must match a schema in anyOf') return '取值不符合预设选项' + return message +} + +/** + * 聚合同一路径下的多条校验信息: + * Zod union(如 duration: 5|10|15)经 AJV anyOf 校验失败时会产生 + * 多条 "must be equal to constant" + 一条 "must match a schema in anyOf", + * 按路径合并为一条可读信息。 + */ +function consolidateDetails(items: { path: string; message: string }[]): { path: string; message: string }[] { + const byPath = new Map() + for (const item of items) { + const list = byPath.get(item.path) ?? [] + list.push(item) + byPath.set(item.path, list) + } + const result: { path: string; message: string }[] = [] + for (const [path, list] of byPath) { + const constFailures = list.filter((i) => i.message === 'must be equal to constant') + const anyOfFailure = list.some((i) => i.message === 'must match a schema in anyOf') + if (anyOfFailure) { + result.push({ path, message: '取值不符合预设选项' }) + continue + } + if (constFailures.length > 0 && list.length === constFailures.length) { + result.push({ path, message: '取值不符合预设选项' }) + continue + } + const seen = new Set() + for (const item of list) { + const msg = translateAjvMessage(item.message) + if (!seen.has(msg)) { + seen.add(msg) + result.push({ path, message: msg }) + } + } + } + return result +} +export function errorHandler(error: FastifyError | ApiError | Error, request: FastifyRequest, reply: FastifyReply) { + if (error instanceof ApiError) { + return sendError(reply, error.statusCode, error.code, error.message, error.details) + } + + const fastifyError = error as FastifyError + if (fastifyError.validation || fastifyError.statusCode === 400) { + const details = consolidateDetails( + (fastifyError.validation ?? []).map((item) => ({ + path: item.instancePath.replace(/^\//, '') || (item.params as { missingProperty?: string })?.missingProperty || '', + message: item.message ?? '参数不合法' + })) + ) + return sendError(reply, 400, 'VALIDATION_ERROR', '请求参数不合法', details) + } + + request.log.error({ err: error }, '未处理异常') + return sendError(reply, 500, 'INTERNAL_ERROR', '服务器内部错误') +} + +export function notFoundHandler(request: FastifyRequest, reply: FastifyReply) { + return sendError(reply, 404, 'NOT_FOUND', `接口不存在:${request.method} ${request.url}`) +} diff --git a/server/src/handlers/ai.ts b/server/src/handlers/ai.ts new file mode 100644 index 0000000..33bffe4 --- /dev/null +++ b/server/src/handlers/ai.ts @@ -0,0 +1,28 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import type { Question } from '../schemas/entities.js' +import type { z } from 'zod' +import { AiExplainBodySchema } from '../schemas/api.js' + +/** + * AI 讲解:返回结构化的讲解结果。 + * TODO 任务11:组装题目上下文调用 OpenAI 兼容接口,超时或未配置 Token 时回退到题库解析。 + * 当前固定返回题库解析回退结果,保证接口契约与前端可以先联调。 + */ +export async function explainQuestion(request: FastifyRequest) { + const body = request.body as z.infer + const questions = await readData(dataFiles.questions, []) + const question = questions.find((q) => q.id === body.questionId) + if (!question) throw ApiError.notFound('题目不存在') + + return { + source: 'fallback' as const, + summary: `AI 讲解尚未接入,已回退到题库标准解析(用户答案:${body.userAnswer})`, + steps: [question.analysis], + knowledgePoints: question.tags, + commonMistakes: [], + answer: question.answer + } +} diff --git a/server/src/handlers/dashboard.ts b/server/src/handlers/dashboard.ts new file mode 100644 index 0000000..004b964 --- /dev/null +++ b/server/src/handlers/dashboard.ts @@ -0,0 +1,79 @@ +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { todayISO } from '../utils/dates.js' +import type { + PracticeRecord, + Profile, + Question, + StudyPlan, + WrongQuestion +} from '../schemas/entities.js' +import type { FastifyRequest } from 'fastify' +import type { z } from 'zod' +import { DashboardTrendsQuerySchema } from '../schemas/api.js' + +const DEFAULT_PROFILE: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: todayISO() } + +/** 数据中枢概览:题目数、作答数、正确率、今日任务等(趋势类统计在任务 05 完善) */ +export async function overview() { + const [profile, questions, records, plans, wrongQuestions] = await Promise.all([ + readData(dataFiles.profile, DEFAULT_PROFILE), + readData(dataFiles.questions, []), + readData(dataFiles.practiceRecords, []), + readData(dataFiles.studyPlans, []), + readData(dataFiles.wrongQuestions, []) + ]) + + const totalAnswered = records.length + const correctCount = records.filter((r) => r.correct).length + const accuracy = totalAnswered === 0 ? 0 : Math.round((correctCount / totalAnswered) * 100) + + const startedMs = Date.parse(`${profile.startedAt}T00:00:00Z`) + const studyDays = Number.isNaN(startedMs) ? 0 : Math.max(1, Math.floor((Date.now() - startedMs) / 86_400_000) + 1) + + const today = todayISO() + const todayPlans = plans.filter((p) => p.date === today) + + return { + studyDays, + totalQuestions: questions.length, + totalAnswered, + accuracy, + streakDays: 0, // TODO 任务05:按自然日连续打卡计算 + todayMinutes: 0, // TODO 任务05:按当日作答耗时汇总 + todayTasksDone: todayPlans.filter((p) => p.status === 'done').length, + todayTasksTotal: todayPlans.length, + pendingReview: wrongQuestions.filter((w) => w.status !== 'mastered').length + } +} + +/** 学习趋势:最近 N 天每日作答数、正确率、学习分钟数(分钟数在任务 05 接入耗时汇总) */ +export async function trends(request: FastifyRequest) { + const { days } = request.query as z.infer + const records = await readData(dataFiles.practiceRecords, []) + + const byDate = new Map() + for (const record of records) { + const date = record.answeredAt.slice(0, 10) + const bucket = byDate.get(date) ?? { answered: 0, correct: 0 } + bucket.answered += 1 + if (record.correct) bucket.correct += 1 + byDate.set(date, bucket) + } + + const today = new Date(`${todayISO()}T00:00:00Z`) + const result = Array.from({ length: days }, (_, i) => { + const date = new Date(today) + date.setUTCDate(today.getUTCDate() - (days - 1 - i)) + const key = date.toISOString().slice(0, 10) + const bucket = byDate.get(key) + return { + date: key, + answered: bucket?.answered ?? 0, + accuracy: bucket && bucket.answered > 0 ? Math.round((bucket.correct / bucket.answered) * 100) : 0, + minutes: 0 // TODO 任务05:接入每日学习时长 + } + }) + + return { days: result } +} diff --git a/server/src/handlers/mock.ts b/server/src/handlers/mock.ts new file mode 100644 index 0000000..f334030 --- /dev/null +++ b/server/src/handlers/mock.ts @@ -0,0 +1,60 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { todayISO } from '../utils/dates.js' +import { newId } from '../utils/ids.js' +import { MODULES } from '../schemas/entities.js' +import type { MockExam, Profile } from '../schemas/entities.js' +import type { z } from 'zod' +import { MockExamRecordBodySchema } from '../schemas/api.js' + +/** 行测模考分析:记录、目标分差、趋势、模块均分 */ +export async function analysis() { + const [records, profile] = await Promise.all([ + readData(dataFiles.mockExams, []), + readData(dataFiles.profile, { + nickname: '备考人', + examDate: '2026-11-30', + targetScore: 72, + startedAt: todayISO() + }) + ]) + + const sorted = [...records].sort((a, b) => a.date.localeCompare(b.date)) + const trend = sorted.map((r) => ({ date: r.date, total: r.total })) + const moduleAverages = MODULES.map((module) => { + const scores = records.map((r) => r.modules[module] ?? 0).filter((s) => s > 0) + const average = scores.length === 0 ? 0 : Math.round(scores.reduce((a, b) => a + b, 0) / scores.length) + return { module, average } + }) + + return { + records: sorted + .slice() + .reverse() + .map((r) => ({ id: r.id, name: r.name, date: r.date, total: r.total, modules: r.modules, note: r.note })), + targetScore: profile.targetScore, + trend, + moduleAverages + } +} + +/** 录入模考成绩。TODO 任务10:持久化并更新趋势与模块分析。 */ +export async function record(request: FastifyRequest) { + const body = request.body as z.infer + return { + id: newId('mock'), + name: body.name, + date: body.date, + total: body.total, + modules: { + '言语理解': body['言语理解'], + '数量关系': body['数量关系'], + '判断推理': body['判断推理'], + '资料分析': body['资料分析'], + '常识判断': body['常识判断'] + }, + note: body.note ?? '', + createdAt: new Date().toISOString() + } +} diff --git a/server/src/handlers/news.ts b/server/src/handlers/news.ts new file mode 100644 index 0000000..4a77ac4 --- /dev/null +++ b/server/src/handlers/news.ts @@ -0,0 +1,75 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import { newId } from '../utils/ids.js' +import type { NewsItem } from '../schemas/entities.js' +import type { z } from 'zod' +import { NewsImportApiBodySchema, NewsImportJsonBodySchema, NewsImportRssBodySchema, NewsImportUrlBodySchema, NewsListQuerySchema } from '../schemas/api.js' + +/** 要闻列表(分类筛选 + 分页) */ +export async function list(request: FastifyRequest) { + const query = request.query as z.infer + const news = await readData(dataFiles.news, []) + const filtered = query.category ? news.filter((n) => n.category === query.category) : news + const sorted = [...filtered].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)) + const start = (query.page - 1) * query.pageSize + return { + items: sorted.slice(start, start + query.pageSize), + total: sorted.length, + page: query.page, + pageSize: query.pageSize + } +} + +/** 要闻详情(Markdown 正文由前端渲染) */ +export async function detail(request: FastifyRequest) { + const { id } = request.params as { id: string } + const news = await readData(dataFiles.news, []) + const item = news.find((n) => n.id === id) + if (!item) throw ApiError.notFound('要闻不存在') + return item +} + +/** JSON 导入要闻。TODO 任务06:校验去重后写入 news.json。 */ +export async function importJson(request: FastifyRequest) { + const body = request.body as z.infer + void newId // 保留引用:任务06 将在此生成要闻 ID + return { total: body.news.length, success: 0, skipped: 0, failed: 0, errors: [] } +} + +/** RSS 导入入口:保留请求结构,解析器在任务06 实现。 */ +export async function importRss(request: FastifyRequest) { + const body = request.body as z.infer + return { + total: 1, + success: 0, + skipped: 0, + failed: 1, + errors: [{ index: 0, message: `RSS 解析器尚未配置(${body.url}),将在要闻功能任务中实现` }] + } +} + +/** API 导入入口:保留请求结构,解析器在任务06 实现。 */ +export async function importApi(request: FastifyRequest) { + const body = request.body as z.infer + return { + total: 1, + success: 0, + skipped: 0, + failed: 1, + errors: [{ index: 0, message: `API 导入未配置(${body.url}),将在要闻功能任务中实现` }] + } +} + +/** URL 导入入口:保留请求结构,解析器在任务06 实现。 */ +export async function importUrl(request: FastifyRequest) { + const body = request.body as z.infer + return { + total: 1, + success: 0, + skipped: 0, + failed: 1, + errors: [{ index: 0, message: `URL 导入未配置(${body.url}),将在要闻功能任务中实现` }] + } +} diff --git a/server/src/handlers/plans.ts b/server/src/handlers/plans.ts new file mode 100644 index 0000000..8df5b12 --- /dev/null +++ b/server/src/handlers/plans.ts @@ -0,0 +1,62 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import { addDays, startOfWeek, todayISO } from '../utils/dates.js' +import { newId } from '../utils/ids.js' +import type { StudyPlan } from '../schemas/entities.js' +import type { z } from 'zod' +import { PlanCreateBodySchema, PlansTodayQuerySchema, PlansWeekQuerySchema } from '../schemas/api.js' + +function daySummary(date: string, tasks: StudyPlan[]) { + return { + date, + tasks, + done: tasks.filter((t) => t.status === 'done').length, + total: tasks.length + } +} + +/** 今日任务 */ +export async function today(request: FastifyRequest) { + const query = request.query as z.infer + const date = query.date ?? todayISO() + const plans = await readData(dataFiles.studyPlans, []) + return daySummary(date, plans.filter((p) => p.date === date)) +} + +/** 本周任务(自然周,周一为起点) */ +export async function week(request: FastifyRequest) { + const query = request.query as z.infer + const weekStart = query.start ?? startOfWeek(todayISO()) + const plans = await readData(dataFiles.studyPlans, []) + const days = Array.from({ length: 7 }, (_, i) => { + const date = addDays(weekStart, i) + return daySummary(date, plans.filter((p) => p.date === date)) + }) + return { weekStart, days } +} + +/** 创建计划任务。TODO 任务06:持久化写入 study-plans.json。 */ +export async function createTask(request: FastifyRequest) { + const body = request.body as z.infer + return { + id: newId('plan'), + date: body.date, + title: body.title, + type: body.type, + ...(body.module ? { module: body.module } : {}), + target: body.target, + ...(body.note ? { note: body.note } : {}), + status: 'pending' as const + } +} + +/** 切换任务完成状态。TODO 任务06:持久化并联动首页统计。 */ +export async function toggleTask(request: FastifyRequest) { + const { id } = request.params as { id: string } + const plans = await readData(dataFiles.studyPlans, []) + const task = plans.find((p) => p.id === id) + if (!task) throw ApiError.notFound('计划任务不存在') + return { id: task.id, status: task.status === 'done' ? 'pending' : 'done' } +} diff --git a/server/src/handlers/practice.ts b/server/src/handlers/practice.ts new file mode 100644 index 0000000..7fffdf1 --- /dev/null +++ b/server/src/handlers/practice.ts @@ -0,0 +1,104 @@ +import type { FastifyRequest } from 'fastify' +import { readData, updateData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import { newId } from '../utils/ids.js' +import { MODULES } from '../schemas/entities.js' +import type { PracticeSession, Question } from '../schemas/entities.js' +import type { z } from 'zod' +import { PracticeStartBodySchema } from '../schemas/api.js' + +/** 五大行测模块与题量 */ +export async function modules() { + const questions = await readData(dataFiles.questions, []) + const modules = MODULES.map((name) => ({ + name, + total: questions.filter((q) => q.module === name).length + })) + return { modules } +} + +/** + * 开始刷题:按模块随机组卷、持久化会话并返回会话视图。 + * TODO 任务08:按 5/10/15 分钟时长换算题量、写入作答记录并影响统计与错题沉淀。 + */ +export async function start(request: FastifyRequest) { + const body = request.body as z.infer + const questions = await readData(dataFiles.questions, []) + const pool = questions.filter((q) => q.module === body.module) + const shuffled = [...pool].sort(() => Math.random() - 0.5) + const questionIds = shuffled.slice(0, 5).map((q) => q.id) + + const session: PracticeSession = { + id: newId('session'), + questionIds, + mode: 'quick', + module: body.module, + durationMinutes: body.duration, + startedAt: new Date().toISOString(), + status: 'active' + } + await updateData(dataFiles.practiceSessions, [], (sessions) => [...sessions, session]) + + return { + sessionId: session.id, + module: session.module, + durationMinutes: session.durationMinutes, + questionIds, + startedAt: session.startedAt, + status: 'active' as const + } +} + +async function findSession(sessionId: string): Promise { + const sessions = await readData(dataFiles.practiceSessions, []) + const session = sessions.find((s) => s.id === sessionId) + if (!session) throw ApiError.notFound('刷题会话不存在') + return session +} + +/** 当前题目(不返回答案与解析) */ +export async function currentQuestion(request: FastifyRequest) { + const { sessionId } = request.params as { sessionId: string } + const session = await findSession(sessionId) + if (session.questionIds.length === 0) throw ApiError.notFound('会话暂无题目') + + const questions = await readData(dataFiles.questions, []) + const question = questions.find((q) => q.id === session.questionIds[0]) + if (!question) throw ApiError.notFound('会话题目不存在于题库') + + return { + question: { + id: question.id, + module: question.module, + subModule: question.subModule, + difficulty: question.difficulty, + stem: question.stem, + options: question.options + }, + index: 0, + total: session.questionIds.length + } +} + +/** 提交单题作答。TODO 任务08:写入作答记录并影响统计与错题沉淀。 */ +export async function answer(request: FastifyRequest) { + const { sessionId } = request.params as { sessionId: string } + const session = await findSession(sessionId) + return { accepted: true, answeredCount: 0 } +} + +/** 结束会话并返回结果。TODO 任务08:真实计算得分、正确率、耗时与错题列表。 */ +export async function finish(request: FastifyRequest) { + const { sessionId } = request.params as { sessionId: string } + const session = await findSession(sessionId) + return { + sessionId: session.id, + total: session.questionIds.length, + correctCount: 0, + accuracy: 0, + durationSeconds: 0, + wrongQuestionIds: [], + questions: [] + } +} diff --git a/server/src/handlers/profile.ts b/server/src/handlers/profile.ts new file mode 100644 index 0000000..93e1344 --- /dev/null +++ b/server/src/handlers/profile.ts @@ -0,0 +1,34 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { todayISO } from '../utils/dates.js' +import type { Profile, Settings } from '../schemas/entities.js' +import type { z } from 'zod' +import { ProfilePatchBodySchema, SettingsPatchBodySchema } from '../schemas/api.js' + +const DEFAULT_PROFILE: Profile = { nickname: '备考人', examDate: '2026-11-30', targetScore: 72, startedAt: todayISO() } +const DEFAULT_SETTINGS: Settings = { dailyTargetMinutes: 90, reminderTime: '19:30', preferredDuration: 15 } + +export async function getProfile() { + return readData(dataFiles.profile, DEFAULT_PROFILE) +} + +/** 修改个人档案。TODO 任务06:持久化写入 profile.json。 */ +export async function patchProfile(request: FastifyRequest) { + const body = request.body as z.infer + const profile = await readData(dataFiles.profile, DEFAULT_PROFILE) + return { ...profile, ...body } +} + +export async function getSettings() { + const settings = await readData(dataFiles.settings, DEFAULT_SETTINGS) + return { ...settings, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) } +} + +/** 修改设置。TODO 任务06:持久化写入 settings.json。 */ +export async function patchSettings(request: FastifyRequest) { + const body = request.body as z.infer + const settings = await readData(dataFiles.settings, DEFAULT_SETTINGS) + const merged = { ...settings, ...body } + return { ...merged, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) } +} diff --git a/server/src/handlers/questions.ts b/server/src/handlers/questions.ts new file mode 100644 index 0000000..74513ed --- /dev/null +++ b/server/src/handlers/questions.ts @@ -0,0 +1,50 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import type { Question } from '../schemas/entities.js' +import type { z } from 'zod' +import { QuestionsImportBodySchema, QuestionsListQuerySchema } from '../schemas/api.js' + +/** 题库列表:模块 / 难度 / 关键词筛选 + 分页 */ +export async function list(request: FastifyRequest) { + const query = request.query as z.infer + const questions = await readData(dataFiles.questions, []) + + const filtered = questions + .filter((q) => (query.module ? q.module === query.module : true)) + .filter((q) => (query.difficulty ? q.difficulty === query.difficulty : true)) + .filter((q) => (query.keyword ? q.stem.includes(query.keyword) : true)) + + const start = (query.page - 1) * query.pageSize + return { + items: filtered.slice(start, start + query.pageSize), + total: filtered.length, + page: query.page, + pageSize: query.pageSize + } +} + +/** 题目 JSON 导入。TODO 任务07:内容指纹去重、UUID 生成、逐条校验报告。 */ +export async function importQuestions(request: FastifyRequest) { + const body = request.body as z.infer + return { total: body.questions.length, success: 0, skipped: 0, failed: 0, errors: [] } +} + +/** 导出当前题库(与导入格式一致,不含 id) */ +export async function exportQuestions() { + const questions = await readData(dataFiles.questions, []) + return { + version: '1.0', + exportedAt: new Date().toISOString(), + questions: questions.map(({ id: _id, createdAt: _createdAt, ...rest }) => rest) + } +} + +/** 删除题目。TODO 任务07:校验引用后从 questions.json 移除。 */ +export async function remove(request: FastifyRequest) { + const { id } = request.params as { id: string } + const questions = await readData(dataFiles.questions, []) + if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在') + return { success: true } +} diff --git a/server/src/handlers/review.ts b/server/src/handlers/review.ts new file mode 100644 index 0000000..05437b4 --- /dev/null +++ b/server/src/handlers/review.ts @@ -0,0 +1,66 @@ +import type { FastifyRequest } from 'fastify' +import { readData } from '../data/store.js' +import { dataFiles } from '../data/files.js' +import { ApiError } from '../errors.js' +import { todayISO } from '../utils/dates.js' +import type { Question, WrongQuestion } from '../schemas/entities.js' +import type { z } from 'zod' +import { ReviewListQuerySchema } from '../schemas/api.js' + +/** 待复习错题列表(支持模块与状态筛选、分页) */ +export async function list(request: FastifyRequest) { + const query = request.query as z.infer + const [wrongQuestions, questions] = await Promise.all([ + readData(dataFiles.wrongQuestions, []), + readData(dataFiles.questions, []) + ]) + + const questionById = new Map(questions.map((q) => [q.id, q])) + const items = wrongQuestions + .filter((w) => (query.module ? questionById.get(w.questionId)?.module === query.module : true)) + .filter((w) => (query.status ? w.status === query.status : true)) + .map((w) => { + const question = questionById.get(w.questionId) + return { + id: w.id, + questionId: w.questionId, + module: question?.module ?? '', + subModule: question?.subModule ?? '', + stem: question?.stem ?? '', + wrongReason: w.wrongReason, + status: w.status, + reviewCount: w.reviewCount, + nextReviewAt: w.nextReviewAt, + updatedAt: w.updatedAt + } + }) + + const start = (query.page - 1) * query.pageSize + return { + items: items.slice(start, start + query.pageSize), + total: items.length, + page: query.page, + pageSize: query.pageSize + } +} + +async function findWrongQuestion(id: string): Promise { + const wrongQuestions = await readData(dataFiles.wrongQuestions, []) + const item = wrongQuestions.find((w) => w.id === id) + if (!item) throw ApiError.notFound('错题记录不存在') + return item +} + +/** 提交复习作答。TODO 任务09:按 +1/+3/+7 天间隔安排下次复习。 */ +export async function submit(request: FastifyRequest) { + const { id } = request.params as { id: string } + const item = await findWrongQuestion(id) + return { correct: false, nextReviewAt: todayISO(), status: item.status } +} + +/** 手动标记已掌握。TODO 任务09:更新错题状态并联动掌握度。 */ +export async function markMastered(request: FastifyRequest) { + const { id } = request.params as { id: string } + const item = await findWrongQuestion(id) + return { id: item.id, status: 'mastered' as const } +} diff --git a/server/src/routes.ts b/server/src/routes.ts new file mode 100644 index 0000000..81b957d --- /dev/null +++ b/server/src/routes.ts @@ -0,0 +1,273 @@ +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 diff --git a/server/src/schemas/api.ts b/server/src/schemas/api.ts new file mode 100644 index 0000000..344b12d --- /dev/null +++ b/server/src/schemas/api.ts @@ -0,0 +1,305 @@ +import { z } from 'zod' +import { + MODULES, + OPTION_KEYS, + DIFFICULTIES, + NewsItemSchema, + ProfileSchema, + QuestionSchema, + SettingsSchema, + StudyPlanSchema +} from './entities.js' + +/** 统一错误响应(需求文档第 6 节格式) */ +export const ErrorDetailSchema = z.object({ + path: z.string(), + message: z.string() +}) +export const ErrorResponseSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + details: z.array(ErrorDetailSchema).default([]) + }) +}) +export type ErrorResponse = z.infer + +const pageSchema = z.number().int().min(1).default(1) +const pageSizeSchema = z.number().int().min(1).max(100).default(20) +const dateParam = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD') + +/** 分页列表公共响应 */ +const pagination = z.object({ + total: z.number().int().min(0), + page: z.number().int().min(1), + pageSize: z.number().int().min(1) +}) + +/** 导入结果(题目 / 要闻共用) */ +export const ImportResultSchema = z.object({ + total: z.number().int().min(0), + success: z.number().int().min(0), + skipped: z.number().int().min(0), + failed: z.number().int().min(0), + errors: z.array(z.object({ index: z.number().int().min(0), message: z.string() })) +}) + +// ---------- 数据中枢 ---------- +export const DashboardOverviewResponseSchema = z.object({ + studyDays: z.number().int().min(0), + totalQuestions: z.number().int().min(0), + totalAnswered: z.number().int().min(0), + accuracy: z.number().int().min(0).max(100), + streakDays: z.number().int().min(0), + todayMinutes: z.number().int().min(0), + todayTasksDone: z.number().int().min(0), + todayTasksTotal: z.number().int().min(0), + pendingReview: z.number().int().min(0) +}) +export const DashboardTrendsQuerySchema = z.object({ + days: z.number().int().min(1).max(90).default(14) +}) +export const DashboardTrendsResponseSchema = z.object({ + days: z.array( + z.object({ + date: dateParam, + answered: z.number().int().min(0), + accuracy: z.number().int().min(0).max(100), + minutes: z.number().int().min(0) + }) + ) +}) + +// ---------- 刷题 ---------- +export const PracticeModulesResponseSchema = z.object({ + modules: z.array(z.object({ name: z.enum(MODULES), total: z.number().int().min(0) })) +}) +export const PracticeStartBodySchema = z.object({ + module: z.enum(MODULES), + duration: z.union([z.literal(5), z.literal(10), z.literal(15)], { + error: '练习时长仅支持 5、10、15 分钟' + }) +}) +export const PracticeSessionViewSchema = z.object({ + sessionId: z.string(), + module: z.enum(MODULES), + durationMinutes: z.number().int().min(1), + questionIds: z.array(z.string()), + startedAt: z.string(), + status: z.enum(['created', 'active', 'finished']) +}) +export const SessionIdParamsSchema = z.object({ sessionId: z.string().min(1) }) +export const PracticeQuestionResponseSchema = z.object({ + question: QuestionSchema.pick({ + id: true, + module: true, + subModule: true, + difficulty: true, + stem: true, + options: true + }), + index: z.number().int().min(0), + total: z.number().int().min(0) +}) +export const PracticeAnswerBodySchema = z.object({ + questionId: z.string().min(1), + answer: z.enum(OPTION_KEYS), + secondsUsed: z.number().int().min(0) +}) +export const PracticeAnswerResponseSchema = z.object({ + accepted: z.boolean(), + answeredCount: z.number().int().min(0) +}) +export const PracticeFinishResponseSchema = z.object({ + sessionId: z.string(), + total: z.number().int().min(0), + correctCount: z.number().int().min(0), + accuracy: z.number().int().min(0).max(100), + durationSeconds: z.number().int().min(0), + wrongQuestionIds: z.array(z.string()), + questions: z.array( + z.object({ + questionId: z.string(), + userAnswer: z.enum(OPTION_KEYS), + correctAnswer: z.enum(OPTION_KEYS), + correct: z.boolean(), + analysis: z.string() + }) + ) +}) + +// ---------- 错题复习 ---------- +export const ReviewListQuerySchema = z.object({ + module: z.enum(MODULES).optional(), + status: z.enum(['pending', 'reviewing', 'mastered']).optional(), + page: pageSchema, + pageSize: pageSizeSchema +}) +export const ReviewListResponseSchema = pagination.extend({ + items: z.array( + z.object({ + id: z.string(), + questionId: z.string(), + module: z.string(), + subModule: z.string(), + stem: z.string(), + wrongReason: z.string(), + status: z.enum(['pending', 'reviewing', 'mastered']), + reviewCount: z.number().int().min(0), + nextReviewAt: z.string(), + updatedAt: z.string() + }) + ) +}) +export const WrongQuestionIdParamsSchema = z.object({ id: z.string().min(1) }) +export const ReviewSubmitBodySchema = z.object({ answer: z.enum(OPTION_KEYS) }) +export const ReviewSubmitResponseSchema = z.object({ + correct: z.boolean(), + nextReviewAt: dateParam, + status: z.enum(['pending', 'reviewing', 'mastered']) +}) +export const ReviewMarkResponseSchema = z.object({ + id: z.string(), + status: z.enum(['pending', 'reviewing', 'mastered']) +}) + +// ---------- 备考计划 ---------- +export const PlansTodayQuerySchema = z.object({ date: dateParam.optional() }) +export const PlansDaySchema = z.object({ + date: dateParam, + tasks: z.array(StudyPlanSchema), + done: z.number().int().min(0), + total: z.number().int().min(0) +}) +export const PlansTodayResponseSchema = PlansDaySchema +export const PlansWeekQuerySchema = z.object({ start: dateParam.optional() }) +export const PlansWeekResponseSchema = z.object({ + weekStart: dateParam, + days: z.array(PlansDaySchema) +}) +export const PlanCreateBodySchema = z.object({ + date: dateParam, + title: z.string().min(1), + type: z.string().min(1), + module: z.enum(MODULES).optional(), + target: z.string().min(1), + note: z.string().optional() +}) +export const PlanTaskIdParamsSchema = z.object({ id: z.string().min(1) }) +export const PlanToggleResponseSchema = z.object({ + id: z.string(), + status: z.enum(['pending', 'done']) +}) + +// ---------- 模考 ---------- +export const MockExamAnalysisResponseSchema = z.object({ + records: z.array(z.object({ + id: z.string(), + name: z.string(), + date: dateParam, + total: z.number().min(0), + modules: z.object({ + '言语理解': z.number().min(0), + '数量关系': z.number().min(0), + '判断推理': z.number().min(0), + '资料分析': z.number().min(0), + '常识判断': z.number().min(0) + }), + note: z.string() + })), + targetScore: z.number().min(0), + trend: z.array(z.object({ date: dateParam, total: z.number().min(0) })), + moduleAverages: z.array(z.object({ module: z.enum(MODULES), average: z.number().min(0) })) +}) +export const MockExamRecordBodySchema = z.object({ + name: z.string().min(1), + date: dateParam, + total: z.number().min(0).max(100), + '言语理解': z.number().min(0).max(100), + '数量关系': z.number().min(0).max(100), + '判断推理': z.number().min(0).max(100), + '资料分析': z.number().min(0).max(100), + '常识判断': z.number().min(0).max(100), + note: z.string().optional() +}) + +// ---------- 要闻 ---------- +export const NewsListQuerySchema = z.object({ + category: z.string().optional(), + page: pageSchema, + pageSize: pageSizeSchema +}) +export const NewsListResponseSchema = pagination.extend({ items: z.array(NewsItemSchema) }) +export const NewsIdParamsSchema = z.object({ id: z.string().min(1) }) +export const NewsImportJsonBodySchema = z.object({ + version: z.string().optional(), + news: z.array( + NewsItemSchema.omit({ id: true }).extend({ + title: z.string().min(1), + category: z.string().min(1), + summary: z.string(), + content: z.string(), + source: z.string(), + publishedAt: z.string(), + tags: z.array(z.string()).default([]), + importSource: z.string().default('json') + }) + ) +}) +export const NewsImportRssBodySchema = z.object({ url: z.string().url() }) +export const NewsImportApiBodySchema = z.object({ + url: z.string().url(), + apiKey: z.string().optional() +}) +export const NewsImportUrlBodySchema = z.object({ url: z.string().url() }) + +// ---------- 题库管理 ---------- +export const QuestionsListQuerySchema = z.object({ + module: z.enum(MODULES).optional(), + difficulty: z.enum(DIFFICULTIES).optional(), + keyword: z.string().optional(), + page: pageSchema, + pageSize: pageSizeSchema +}) +export const QuestionsListResponseSchema = pagination.extend({ items: z.array(QuestionSchema) }) +export const QuestionIdParamsSchema = z.object({ id: z.string().min(1) }) +export const QuestionsImportBodySchema = z.object({ + version: z.string().optional(), + questions: z.array( + QuestionSchema.omit({ id: true, createdAt: true }) + ) +}) +export const QuestionsExportResponseSchema = z.object({ + version: z.string(), + exportedAt: z.string(), + questions: z.array(QuestionSchema.omit({ id: true, createdAt: true })) +}) +export const QuestionsDeleteResponseSchema = z.object({ success: z.boolean() }) + +// ---------- AI 讲解 ---------- +export const AiExplainBodySchema = z.object({ + questionId: z.string().min(1), + userAnswer: z.enum(OPTION_KEYS), + requestType: z.enum(['standard', 'deep']).default('standard') +}) +export const AiExplainResponseSchema = z.object({ + source: z.enum(['ai', 'fallback']), + summary: z.string(), + steps: z.array(z.string()), + knowledgePoints: z.array(z.string()), + commonMistakes: z.array(z.string()), + answer: z.enum(OPTION_KEYS) +}) + +// ---------- 个人档案 / 设置 ---------- +export const ProfilePatchBodySchema = ProfileSchema.partial() +export const ProfileResponseSchema = ProfileSchema +export const SettingsResponseSchema = SettingsSchema.extend({ + aiConfigured: z.boolean() +}) +export const SettingsPatchBodySchema = SettingsSchema.partial() + +/** 统一分页信息提取(供 handlers 使用) */ +export function paginationMeta(total: number, page: number, pageSize: number) { + return { total, page, pageSize } +} diff --git a/server/src/schemas/entities.ts b/server/src/schemas/entities.ts new file mode 100644 index 0000000..4c1be35 --- /dev/null +++ b/server/src/schemas/entities.ts @@ -0,0 +1,122 @@ +import { z } from 'zod' + +/** 行测固定模块 */ +export const MODULES = ['言语理解', '数量关系', '判断推理', '资料分析', '常识判断'] as const +export const DIFFICULTIES = ['简单', '中等', '困难'] as const +export const OPTION_KEYS = ['A', 'B', 'C', 'D'] as const + +export const QuestionOptionSchema = z.object({ + key: z.enum(OPTION_KEYS), + text: z.string().min(1) +}) + +/** 题库中的完整题目(含后台生成的字段) */ +export const QuestionSchema = z.object({ + id: z.string().min(1), + type: z.literal('行测'), + module: z.enum(MODULES), + subModule: z.string(), + difficulty: z.enum(DIFFICULTIES), + stem: z.string().min(1), + options: z.array(QuestionOptionSchema), + answer: z.enum(OPTION_KEYS), + analysis: z.string(), + tags: z.array(z.string()), + source: z.string(), + createdAt: z.string() +}) +export type Question = z.infer + +/** 题目导入格式(不含 id,由后台生成) */ +export const QuestionInputSchema = QuestionSchema.omit({ id: true, createdAt: true }) +export type QuestionInput = z.infer + +export const ProfileSchema = z.object({ + nickname: z.string().min(1), + examDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD'), + targetScore: z.number().int().min(0).max(100), + startedAt: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, '日期格式应为 YYYY-MM-DD') +}) +export type Profile = z.infer + +/** settings.json 存储结构(AI 配置状态由环境变量计算,不落盘) */ +export const SettingsSchema = z.object({ + dailyTargetMinutes: z.number().int().min(0), + reminderTime: z.string().regex(/^\d{2}:\d{2}$/, '时间格式应为 HH:mm'), + preferredDuration: z.union([z.literal(5), z.literal(10), z.literal(15)]) +}) +export type Settings = z.infer + +export const NewsItemSchema = z.object({ + id: z.string().min(1), + title: z.string().min(1), + category: z.string(), + summary: z.string(), + content: z.string(), + source: z.string(), + publishedAt: z.string(), + tags: z.array(z.string()), + importSource: z.string() +}) +export type NewsItem = z.infer + +export const StudyPlanSchema = z.object({ + id: z.string().min(1), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + title: z.string().min(1), + type: z.string(), + module: z.string().optional(), + target: z.string(), + status: z.enum(['pending', 'done']) +}) +export type StudyPlan = z.infer + +export const MockExamSchema = z.object({ + id: z.string().min(1), + name: z.string().min(1), + date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), + total: z.number().min(0).max(100), + modules: z.object({ + '言语理解': z.number().min(0).max(100), + '数量关系': z.number().min(0).max(100), + '判断推理': z.number().min(0).max(100), + '资料分析': z.number().min(0).max(100), + '常识判断': z.number().min(0).max(100) + }), + note: z.string(), + createdAt: z.string() +}) +export type MockExam = z.infer + +export const WrongQuestionSchema = z.object({ + id: z.string().min(1), + questionId: z.string().min(1), + wrongReason: z.string(), + status: z.enum(['pending', 'reviewing', 'mastered']), + reviewCount: z.number().int().min(0), + nextReviewAt: z.string(), + updatedAt: z.string() +}) +export type WrongQuestion = z.infer + +export const PracticeSessionSchema = z.object({ + id: z.string().min(1), + questionIds: z.array(z.string()), + mode: z.string(), + module: z.string(), + durationMinutes: z.number().int().min(1), + startedAt: z.string(), + endedAt: z.string().optional(), + status: z.enum(['active', 'finished']) +}) +export type PracticeSession = z.infer + +export const PracticeRecordSchema = z.object({ + questionId: z.string().min(1), + sessionId: z.string().min(1), + userAnswer: z.enum(OPTION_KEYS), + correct: z.boolean(), + secondsUsed: z.number().int().min(0), + answeredAt: z.string() +}) +export type PracticeRecord = z.infer diff --git a/server/src/server.ts b/server/src/server.ts index 89daec9..3b027f2 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,9 +1,59 @@ +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' -const app = Fastify({ logger: true }) -await app.register(cors, { origin: true }) -await initializeData() -app.get('/health', async () => ({ status: 'ok', service: 'gwy-server' })) -app.listen({ port: Number(process.env.PORT ?? 3000), host: process.env.HOST ?? '0.0.0.0' }) +/** 构建 Fastify 实例(不监听端口,供运行与 OpenAPI 导出共用) */ +export async function buildServer() { + const app = Fastify({ logger: true, exposeHeadRoutes: false }) + + await app.register(cors, { origin: true }) + + 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' }) +} diff --git a/server/src/utils/dates.ts b/server/src/utils/dates.ts new file mode 100644 index 0000000..a3cc51b --- /dev/null +++ b/server/src/utils/dates.ts @@ -0,0 +1,26 @@ +/** 日期工具:统一使用本地日期的 YYYY-MM-DD 形式 */ +export function dateOnly(date: Date): string { + const y = date.getFullYear() + const m = `${date.getMonth() + 1}`.padStart(2, '0') + const d = `${date.getDate()}`.padStart(2, '0') + return `${y}-${m}-${d}` +} + +export function todayISO(): string { + return dateOnly(new Date()) +} + +/** 在 YYYY-MM-DD 上加减天数 */ +export function addDays(date: string, days: number): string { + const d = new Date(`${date}T00:00:00Z`) + d.setUTCDate(d.getUTCDate() + days) + return d.toISOString().slice(0, 10) +} + +/** 返回 date 所在周的周一(自然周,周一为第一天) */ +export function startOfWeek(date: string): string { + const d = new Date(`${date}T00:00:00Z`) + const dayOfWeek = (d.getUTCDay() + 6) % 7 + d.setUTCDate(d.getUTCDate() - dayOfWeek) + return d.toISOString().slice(0, 10) +} diff --git a/server/src/utils/ids.ts b/server/src/utils/ids.ts new file mode 100644 index 0000000..1185f24 --- /dev/null +++ b/server/src/utils/ids.ts @@ -0,0 +1,6 @@ +import { randomUUID } from 'node:crypto' + +/** 生成带业务前缀的 ID(导入题目、计划任务、模考记录等) */ +export function newId(prefix: string): string { + return `${prefix}-${randomUUID()}` +}