feat: 添加后端接口

This commit is contained in:
liyy 2026-08-31 16:48:49 +08:00
parent 98505f3a91
commit 4b2c80bef1
19 changed files with 1771 additions and 28 deletions

60
docs/checks/task-03.md Normal file
View File

@ -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`200OpenAPI 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`

259
pnpm-lock.yaml generated
View File

@ -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: {}

View File

@ -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"
}
}

110
server/src/errors.ts Normal file
View File

@ -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<string, { path: string; message: string }[]>()
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<string>()
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}`)
}

28
server/src/handlers/ai.ts Normal file
View File

@ -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<typeof AiExplainBodySchema>
const questions = await readData<Question[]>(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
}
}

View File

@ -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<Profile>(dataFiles.profile, DEFAULT_PROFILE),
readData<Question[]>(dataFiles.questions, []),
readData<PracticeRecord[]>(dataFiles.practiceRecords, []),
readData<StudyPlan[]>(dataFiles.studyPlans, []),
readData<WrongQuestion[]>(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<typeof DashboardTrendsQuerySchema>
const records = await readData<PracticeRecord[]>(dataFiles.practiceRecords, [])
const byDate = new Map<string, { answered: number; correct: number }>()
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 }
}

View File

@ -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<MockExam[]>(dataFiles.mockExams, []),
readData<Profile>(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<typeof MockExamRecordBodySchema>
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()
}
}

View File

@ -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<typeof NewsListQuerySchema>
const news = await readData<NewsItem[]>(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<NewsItem[]>(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<typeof NewsImportJsonBodySchema>
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<typeof NewsImportRssBodySchema>
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<typeof NewsImportApiBodySchema>
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<typeof NewsImportUrlBodySchema>
return {
total: 1,
success: 0,
skipped: 0,
failed: 1,
errors: [{ index: 0, message: `URL 导入未配置(${body.url}),将在要闻功能任务中实现` }]
}
}

View File

@ -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<typeof PlansTodayQuerySchema>
const date = query.date ?? todayISO()
const plans = await readData<StudyPlan[]>(dataFiles.studyPlans, [])
return daySummary(date, plans.filter((p) => p.date === date))
}
/** 本周任务(自然周,周一为起点) */
export async function week(request: FastifyRequest) {
const query = request.query as z.infer<typeof PlansWeekQuerySchema>
const weekStart = query.start ?? startOfWeek(todayISO())
const plans = await readData<StudyPlan[]>(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<typeof PlanCreateBodySchema>
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<StudyPlan[]>(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' }
}

View File

@ -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<Question[]>(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<typeof PracticeStartBodySchema>
const questions = await readData<Question[]>(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<PracticeSession[]>(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<PracticeSession> {
const sessions = await readData<PracticeSession[]>(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<Question[]>(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: []
}
}

View File

@ -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<Profile>(dataFiles.profile, DEFAULT_PROFILE)
}
/** 修改个人档案。TODO 任务06持久化写入 profile.json。 */
export async function patchProfile(request: FastifyRequest) {
const body = request.body as z.infer<typeof ProfilePatchBodySchema>
const profile = await readData<Profile>(dataFiles.profile, DEFAULT_PROFILE)
return { ...profile, ...body }
}
export async function getSettings() {
const settings = await readData<Settings>(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<typeof SettingsPatchBodySchema>
const settings = await readData<Settings>(dataFiles.settings, DEFAULT_SETTINGS)
const merged = { ...settings, ...body }
return { ...merged, aiConfigured: Boolean(process.env.AI_BASE_URL && process.env.AI_API_KEY) }
}

View File

@ -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<typeof QuestionsListQuerySchema>
const questions = await readData<Question[]>(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<typeof QuestionsImportBodySchema>
return { total: body.questions.length, success: 0, skipped: 0, failed: 0, errors: [] }
}
/** 导出当前题库(与导入格式一致,不含 id */
export async function exportQuestions() {
const questions = await readData<Question[]>(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<Question[]>(dataFiles.questions, [])
if (!questions.some((q) => q.id === id)) throw ApiError.notFound('题目不存在')
return { success: true }
}

View File

@ -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<typeof ReviewListQuerySchema>
const [wrongQuestions, questions] = await Promise.all([
readData<WrongQuestion[]>(dataFiles.wrongQuestions, []),
readData<Question[]>(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<WrongQuestion> {
const wrongQuestions = await readData<WrongQuestion[]>(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 }
}

273
server/src/routes.ts Normal file
View File

@ -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<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

305
server/src/schemas/api.ts Normal file
View File

@ -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<typeof ErrorResponseSchema>
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 }
}

View File

@ -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<typeof QuestionSchema>
/** 题目导入格式(不含 id由后台生成 */
export const QuestionInputSchema = QuestionSchema.omit({ id: true, createdAt: true })
export type QuestionInput = z.infer<typeof QuestionInputSchema>
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<typeof ProfileSchema>
/** 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<typeof SettingsSchema>
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<typeof NewsItemSchema>
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<typeof StudyPlanSchema>
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<typeof MockExamSchema>
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<typeof WrongQuestionSchema>
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<typeof PracticeSessionSchema>
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<typeof PracticeRecordSchema>

View File

@ -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' })
}

26
server/src/utils/dates.ts Normal file
View File

@ -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)
}

6
server/src/utils/ids.ts Normal file
View File

@ -0,0 +1,6 @@
import { randomUUID } from 'node:crypto'
/** 生成带业务前缀的 ID导入题目、计划任务、模考记录等 */
export function newId(prefix: string): string {
return `${prefix}-${randomUUID()}`
}