feat: 添加后端接口
This commit is contained in:
parent
98505f3a91
commit
4b2c80bef1
60
docs/checks/task-03.md
Normal file
60
docs/checks/task-03.md
Normal 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`: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`。
|
||||
259
pnpm-lock.yaml
generated
259
pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
@ -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
110
server/src/errors.ts
Normal 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
28
server/src/handlers/ai.ts
Normal 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
|
||||
}
|
||||
}
|
||||
79
server/src/handlers/dashboard.ts
Normal file
79
server/src/handlers/dashboard.ts
Normal 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 }
|
||||
}
|
||||
60
server/src/handlers/mock.ts
Normal file
60
server/src/handlers/mock.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
75
server/src/handlers/news.ts
Normal file
75
server/src/handlers/news.ts
Normal 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}),将在要闻功能任务中实现` }]
|
||||
}
|
||||
}
|
||||
62
server/src/handlers/plans.ts
Normal file
62
server/src/handlers/plans.ts
Normal 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' }
|
||||
}
|
||||
104
server/src/handlers/practice.ts
Normal file
104
server/src/handlers/practice.ts
Normal 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: []
|
||||
}
|
||||
}
|
||||
34
server/src/handlers/profile.ts
Normal file
34
server/src/handlers/profile.ts
Normal 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) }
|
||||
}
|
||||
50
server/src/handlers/questions.ts
Normal file
50
server/src/handlers/questions.ts
Normal 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 }
|
||||
}
|
||||
66
server/src/handlers/review.ts
Normal file
66
server/src/handlers/review.ts
Normal 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
273
server/src/routes.ts
Normal 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
305
server/src/schemas/api.ts
Normal 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 }
|
||||
}
|
||||
122
server/src/schemas/entities.ts
Normal file
122
server/src/schemas/entities.ts
Normal 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>
|
||||
@ -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
26
server/src/utils/dates.ts
Normal 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
6
server/src/utils/ids.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
|
||||
/** 生成带业务前缀的 ID(导入题目、计划任务、模考记录等) */
|
||||
export function newId(prefix: string): string {
|
||||
return `${prefix}-${randomUUID()}`
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user