2026-08-26 16:20:55 +08:00

640 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 备考中枢 · 系统架构设计
> 版本v2.0 · 2026-08-26
> 角色:首席架构师 高见远
> 范围:仅架构设计。不含业务代码、不含设计规范、不含 PRD均已存在
> 依据用户已拍板选型Express + Vue3 + Vite + 本地 JSON 文件 + 全 TypeScript 禁 any作为**不可推翻基线**;本文件在此基线上做工程化落地与可行性确证。
---
## 一、设计目标与核心结论
「备考中枢」是**个人单用户、自用型**公务员备考数据中枢。移动优先(碎片双峰早 7-9 / 晚 19:30-22:30 为主战场),桌面兼顾整块时间模考与复盘导出。非 SaaS、非多租户、无社区、不办大规模模考、不做原生 App。
**产品核心闭环**:录入题库 → 分模块刷题 → 答错自动进错题本 → 打错因标签 → 考点×错因归因 →AI 二期增强)→ 数据中枢聚合。
### 核心结论速览
| 项 | 结论 |
|---|---|
| 分层数 | 3 层Handler 层 / Service 层 / 数据访问 + 领域类型层),依赖只向下 |
| 组织方式 | 垂直切片:一个资源 = 一个 route 文件(内含 handler+ 一个 service 文件;请求校验、调 service、组装响应集中在 route handler |
| 实体数 | 8 个领域实体Question / AnswerRecord / WrongQuestion / MockExam / ShenLunEssay / StudyTask / StatsSnapshot / AppSettings |
| 关键工程化亮点 | **单一契约源zod + zod-openapi**:领域类型由 `z.infer` 自动生成、OpenAPI 契约由 `zod-openapi` 自动反推、请求校验用同一 zod schema三者同步本地 JSON 数据层采用「zod 运行时校验 + 临时文件原子 rename + 写队列串行」三重防护,保证数据资产永不损坏;全链路 0 any |
| 最大风险 | 统计聚合的 O(n) 全量扫描与 JSON 文件体积随作答记录增长 |
| 结论 | Express + 本地 JSON 对「个人单用户」场景**完全可行**,仅统计聚合与大文件写入需要防护策略(详见 §九 风险),不需更换存储或架构 |
---
## 二、版本锚定(技术选型确证)
> 依据 npm registry / 官方发布信息2026-08-26 联网核实)。以下为**当前最新稳定线**;若实际安装版本不同,以锁定后 `package.json` 为准,**禁止按通用印象写 API**。
| 技术 | 版本锚定 | 说明 |
|---|---|---|
| Node.js | **24.xLTS / Krypton** | 生产应使用 Active/Maintenance LTS。Node 26 为 Current未进入 Active LTS不用于生产。Express 5 要求 Node ≥ 1824 LTS 满足且有余量。 |
| Express | **5.2.x** | Express 5 已是 npm `latest` 默认线3/2025 切换v4 已进入 MaintenanceEOL 不早于 2026-10-01。要求 Node ≥ 18。 |
| Vue | **3.5.x** | 官方 create-vue 脚手架默认 3.5.xVapor Mode 为 3.6 RC不启用。 |
| Vite | **6.x** | Vue 3.5 脚手架默认构建工具Node ≥ 18。若安装为 Vite 7 亦可,不强制。 |
| TypeScript | **5.8.x** | 配合 strict 模式 + `any` 全禁。 |
| zod | **4.x当前稳定线** | 运行时校验 + 类型收窄,是「从 JSON 文件读数据 + 无 any」的硬依赖。`z.infer` 产出具名类型。 |
| @asteasolutions/zod-to-openapi | **8.x配合 zod 4** | 单一契约源的核心:从 zod schema 自动反推 OpenAPI 3.0/3.1 契约,无需手写 `openapi.yaml`。若项目锁定 zod 3.x其匹配线为 `v7.3.4`(本项目不采用)。 |
| @types/express | 匹配 Express 5.x | 装 5.x 对应类型定义。 |
| pnpm | 10+ | monorepo workspace 包管理器。 |
| 包格式 | ESM | 统一 ES ModuleExpress 5 原生支持 import。 |
**zod-openapi 用法要点**mainstream已联网确认
- 入口(`app.ts`)调用一次 `extendZodWithOpenApi(z)`,使所有 zod 类型获得 `.openapi()` 元数据扩展。
-`OpenAPIRegistry.register(name, schema)` 把 zod schema 注册为 `components/schemas` 的具名组件;用 `registerPath({ method, path, request, responses })` 注册端点。
- `OpenApiGeneratorV3``V31``generateDocument()` 一次性产出完整 OpenAPI 文档;`generateComponents()` 只产出 `components` 段。
- 同一份 zod schema 同时驱动三处:**请求运行时校验**`schema.parse(raw)`)、**TS 类型**`z.infer`)、**OpenAPI 契约**`generateDocument`),三者永不失同步。
- 前端据此契约:用 `orval` / `@hey-api/openapi-ts` 生成 TS 请求/响应类型与 fetch 封装(见 `packages/web/src/types/`),或直接复用 `packages/shared``z.infer` 类型。
- **规避 zod 4 的查询参数坑**query 参数一律是字符串,需用 `z.coerce.number()` / `z.coerce.boolean()` 做显式强转,否则 `?page=2` 会因类型不符而校验失败。
- **规避校验结果丢失**:校验后必须把 `parsed` 结果写回 `req.body`(或注入 handler 局部变量),不要继续读原始 `req.body`,否则 defaults/coercions 不生效。
- **规避契约漂移**:在 dev 中间件中对出站响应做一次 schema 校验,把「返回值与契约不一致」在开发期就暴露。
> **版本锚定回写**:执行 Phase 2 安装后,须把 `package.json` / `pnpm-lock.yaml` 中的精确版本回写本文件「版本锚定」表,使规格与实现同步(见 `spec-as-contract`:版本锚定必须按实际安装写)。
---
## 三、Express + 本地 JSON 数据层:取舍正反评估
> 这是架构的关键约束。数据存储明确选定 JSON 文件(非 SQLite。以下为对个人工具的**合理性论证**与**代价及规避策略**,作为 ADR-003 的正文依据。
### 3.1 为什么对个人工具合理(正面)
| 维度 | 说明 |
|---|---|
| 单机低运维 | 自用工具无多实例、无并发在线用户。JSON 文件天然零安装、零连接串、零迁移脚本。Docker 起一个容器即用。 |
| 数据资产可读可迁移 | 数据是纯文件,用户可 `cat`/`jq` 直接读、可 git 跟踪、可一键备份到网盘/移动硬盘,跨设备迁移无锁定成本。对「备考数据是个人长期资产」这个心智高度契合。 |
| 无需 DB 部署 | 个人场景开 PostgreSQL 属杀鸡用牛刀JSON + 内存聚合足够覆盖单用户数据量级。 |
| 开发速度快 | 无 ORM 映射、无迁移脚本、无连接池,`data/store.ts` 直接用 `fs` 读写MVP 工作量最小。 |
| DevOps 极简 | Docker volume 挂载数据目录即持久化;备份即拷贝目录。 |
### 3.2 代价与规避策略(必须正视,不能回避)
| 代价 | 具体风险 | 规避策略 |
|---|---|---|
| 并发写 | 两个请求同时写同一文件导致覆盖或损坏 | **写队列串行**:单进程内所有写操作走一个 Promise 队列(互斥),逐个执行,杜绝交错写。 |
| 写入非原子 | 进程崩溃时写一半,文件损坏 | **临时文件 + rename 原子替换**:先写 `.tmp`,再 `fs.rename` 替换(同文件系统内 rename 原子)。 |
| 无事务 | 跨实体操作(如「错答 → 入错题本 + 增量统计」)任一步失败会导致数据不一致 | **单文件原子写 + 分解最小写单元**:每个实体独立文件,单文件内操作原子;跨实体的组合操作用「先写主实体、失败补偿 → 统计用内存快照重算」策略,统计始终可重建,不追求强一致。 |
| 体积增长 | 作答记录 file 随刷题量增长,全量读入内存成本上升 | **惰性加载 + 实体分文件**:仅按需读入某实体;作答记录达到阈值后做**归档分片**(按月切分 answer-records-YYYY-MM.json增量统计在内存聚合。 |
| 无事务/一致性 | 统计与明细可能短暂不一致 | 统计采用「**内存聚合 + 快照持久化**」,明细写时同步更新内存聚合,快照定期落盘;明细永远可信,统计可随时由明细重新计算。 |
| 正文检索 | JSON 无索引,全文过滤为 O(n) | 单用户量级 O(n) 内存扫描足够k 万条/秒级仅对高频过滤字段module/status/日期)做**内存字段索引**Map 缓存),避免全量遍历。 |
**结论**Express + 本地 JSON 对「个人单用户备考工具」是**成本最低、数据资产感最强**的合理取舍;其并发/事务/体积三类代价均有明确规避策略,**不需要**升级到 SQLite 或 PostgreSQL若未来数据量剧增或需多设备协作再以 ADR 追加迁移,不影响本期闭环)。
---
## 四、垂直切片 3 层架构ASCII 图)
本架构采用**垂直切片**组织方式,而非按层堆放大目录。一个资源被「纵向切透」——从 HTTP 入口到数据落盘端到端归属一个切片,切片内按职责分为三层,层与层之间**依赖只向下**。
```
┌────────────────────────────────────────────────────────────────────────────┐
│ Handler 层 · 垂直切片入口 │
│ routes/*.routes.ts │
│ · 路由注册 + zod 请求校验schema.parse → parsed
│ · 调用对应 service │
│ · 组装统一响应 ApiResponse<T>(成功/失败) │
│ middlewares/ error / not-found / request-log │
└──────────────────────────┬─────────────────────────────────────────────────┘
│ 仅依赖 ↓import
┌──────────────────────────▼─────────────────────────────────────────────────┐
│ Service 层 │
│ services/*.service.ts │
│ · 跨实体业务逻辑(抽题 / 判定 / 错题入本 / 增量聚合 / 备份还原) │
│ · 用例编排、事务性组合、调用 data/store │
│ · 不 import req/res、不返回 HTTP 响应,只返回业务结果 / 抛业务异常 │
│ · authN单用户简单口令校验
└──────────────────────────┬─────────────────────────────────────────────────┘
│ 仅依赖 ↓import
┌──────────────────────────▼─────────────────────────────────────────────────┐
│ 数据访问 + 领域类型层 │
│ data/store.ts JsonStore 单例:读 JSON(zod 校验) + 写队列 + 原子写 │
│ data/file-map.ts APP_DATA_DIR 解析、文件命名 │
│ packages/shared/schemas/ zod schema领域类型由 z.infer 产出,单一契约源) │
│ packages/shared/api/response.ts ApiResponse<T> 泛型 │
│ utils/ 纯工具函数id / 日期 / 聚合,无业务无副作用) │
└──────────────────────────┬─────────────────────────────────────────────────┘
┌──────────────────────────▼─────────────────────────────────────────────────┐
│ 基础设施 Infrastructure │
│ 文件系统(数据目录) · 环境变量config · Docker volume 挂载点 │
└────────────────────────────────────────────────────────────────────────────┘
```
> **AI 扩展位(二期,本期只留壳不实现)**:位于 Service 层,`services/ai/` 目录占位 + `providers/` 抽象接口;通过 Feature Flag 控制,见 §十。
**垂直切片 vs 横向堆叠的本质区分**:横向堆叠会把一个资源拆成 `routes + controllers + validators + services + repositories + stores` 六处,改一个接口字段要在多个目录间跳动;垂直切片把「路由 + 校验 + 编排 + 组装响应」收拢进一个 route 文件,跨实体复杂业务集中在对应 service 文件,数据存取合并进 `data/`,改一个接口往往只需动**一个 route 文件 + 一个 service 文件**。跨切片的实体业务(如答错同时写 answer-record、wrong-question 并增量更新统计)由 service 层统一承载,这是 service 层在本架构内的真实价值所在。
---
## 五、可执行目录结构与分包
### 5.1 Monorepo 布局ADR-005
采用 **pnpm workspace** 单仓多包,`apps/server` + `apps/web` + `packages/shared`。类型契约与 zod schema 在 `shared`,前后端共用,避免复制粘贴导致「同一模型多处定义漂移」。
```
gwy-exam/
├── pnpm-workspace.yaml
├── package.json # 仅脚本 + workspace 包管理器,不装业务依赖
├── tsconfig.base.json # 共享 TS 编译基座strict / noImplicitAny / exact...
├── .env.example # APP_DATA_DIR / PORT 等示例
├── Dockerfile
├── docker-compose.yml # server + volume 挂载数据目录
├── apps/
│ ├── server/ # Express 5 + TS + ESM
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── src/
│ │ │ ├── app.ts # 入口只装配extendZodWithOpenApi + 挂中间件+路由+错误处理+启动0 业务
│ │ │ ├── server.ts # 可选:监听与优雅关闭,仍只装配
│ │ │ ├── config/
│ │ │ │ ├── env.ts # 环境变量读取 + zod 校验,输出强类型 config
│ │ │ │ └── app-config.ts
│ │ │ ├── routes/ # 垂直切片入口:路由 + handler校验 + 调 service + 组装响应)
│ │ │ │ ├── index.ts # 路由聚合(/api/v1+ openapi 契约生成
│ │ │ │ ├── questions.routes.ts
│ │ │ │ ├── practice.routes.ts
│ │ │ │ ├── wrong-questions.routes.ts
│ │ │ │ ├── mock-exams.routes.ts
│ │ │ │ ├── shenlun.routes.ts
│ │ │ │ ├── tasks.routes.ts
│ │ │ │ ├── stats.routes.ts
│ │ │ │ ├── settings.routes.ts
│ │ │ │ └── data.routes.ts # 数据资产:导出 / 备份 / 还原
│ │ │ ├── services/ # 跨实体业务用例(不 import req/res
│ │ │ │ ├── question.service.ts
│ │ │ │ ├── practice.service.ts # 抽题 + 判定 + 错题入本 + 统计增量
│ │ │ │ ├── wrong-question.service.ts
│ │ │ │ ├── mock-exam.service.ts
│ │ │ │ ├── shenlun.service.ts
│ │ │ │ ├── task.service.ts
│ │ │ │ ├── stats.service.ts # 内存聚合 + 快照
│ │ │ │ ├── settings.service.ts
│ │ │ │ ├── data-asset.service.ts # 导出/备份/还原
│ │ │ │ └── ai/ # 二期占位(详见 §十)
│ │ │ │ └── providers/ # provider 接口 + 空实现
│ │ │ ├── data/ # JSON 数据层核心(§六)
│ │ │ │ ├── store.ts # JsonStore 单例:写队列 + 原子写 + 惰性缓存 + zod 校验
│ │ │ │ └── file-map.ts # APP_DATA_DIR 解析、file 命名
│ │ │ ├── middlewares/
│ │ │ │ ├── error.middleware.ts
│ │ │ │ ├── not-found.middleware.ts
│ │ │ │ └── response-validate.middleware.ts # dev出站响应 zod 校验
│ │ │ ├── errors/
│ │ │ │ ├── app-error.ts # 错误类型联合 + 错误码
│ │ │ │ └── error-codes.ts
│ │ │ └── utils/
│ │ │ ├── id.ts # crypto.randomUUID()
│ │ │ └── date.ts # ISO 时间 / 日粒度 key
│ │ └── data/ # 运行时数据目录gitignoreAPP_DATA_DIR 默认指向)
│ │ └── tests/ # 单元测试service / data
│ ├── web/ # Vue3 + Vite + TS
│ │ ├── package.json
│ │ ├── tsconfig.json
│ │ ├── vite.config.ts
│ │ ├── src/
│ │ │ ├── main.ts
│ │ │ ├── App.vue
│ │ │ ├── router/ # Vue Router 4 配置
│ │ │ ├── views/ # 页面组件(按产品页面分包)
│ │ │ │ ├── dashboard/ # 数据中枢
│ │ │ │ ├── question-entry/ # 题库录入
│ │ │ │ ├── practice/ # 刷题中心(刷题态/错题态/申论态)
│ │ │ │ ├── mock-exam/ # 模考分析
│ │ │ │ ├── task/ # 备考计划
│ │ │ │ ├── news/ # 要闻
│ │ │ │ └── settings/ # 设置
│ │ │ ├── components/ # 通用组件(按需拆分 + 单文件 ≤ 300 行)
│ │ │ ├── composables/ # 组合式函数useQuestions / useStats 等)
│ │ │ ├── stores/ # Pinia
│ │ │ ├── services/ # API 封装fetch 统一封装 + ApiResponse<T> 解析)
│ │ │ │ └── api-client.ts
│ │ │ ├── types/ # 由 openapi 契约生成的 TS 类型orval/@hey-api或复用 shared z.infer
│ │ │ ├── styles/ # 设计 token来自设计规范 CSS 变量)
│ │ │ └── mocks/ # MSW Mock依 openapi 契约生成)
│ │ └── public/
└── packages/
└── shared/ # 前后端共享单一契约源zod schema + z.infer 类型 + 常量)
├── package.json
├── tsconfig.json
├── src/
│ ├── schemas/ # zod schema = 领域类型 + 数据 schema + 请求 schema单一契约源
│ │ ├── question.schema.ts
│ │ ├── practice.schema.ts
│ │ ├── wrong-question.schema.ts
│ │ ├── mock-exam.schema.ts
│ │ ├── shenlun.schema.ts
│ │ ├── task.schema.ts
│ │ ├── stats.schema.ts
│ │ ├── settings.schema.ts
│ │ └── index.ts # 统一导出 + z.infer 具名类型
│ ├── api/response.ts # ApiResponse<T> 泛型
│ ├── api/error-codes.ts # 错误码联合
│ └── constants.ts # 模块中文名映射等
└── index.ts
```
### 5.2 文件组织硬规则(对照 `code-organization.md` §4出现即不合格
| # | 规则 | 本项目落地 |
|---|---|---|
| 1 | 单文件 ≤ 300 行 | route/service/schema 每文件一个;超限按子功能拆文件,不拆函数凑数 |
| 2 | 单一职责 | route handler 只编排 + 组装响应service 只业务;`data/` 只存取 |
| 3 | 按资源分包 | 每资源 = route + service +可选schema 切片;`data/``packages/shared/schemas` 横跨复用 |
| 4 | 入口只装配 | `app.ts` 只挂中间件 + 路由 + 错误处理 + 启动0 业务逻辑 |
| 5 | 业务不进路由处理器 | route handler 内只做 zod 校验 + 调 service + 组装响应,跨实体逻辑全部下沉 service |
| 6 | 依赖只向下、不反向、不跨层 | route→service→dataservice 不 import req/res跨切片走对方 service 接口 |
| 7 | utils 纯净 | `utils/` 只放纯函数id / 日期 / 聚合),无业务无副作用 |
| 8 | 类型/schema 由 zod 单一契约源产出 | 领域类型用 `z.infer`openapi 契约用 `zod-openapi` 自动生成,均不自维护另一份定义 |
**门禁命令**Phase 2 交付前执行):
```bash
# 超 300 行即退回
find apps -name '*.ts' -o -name '*.vue' | xargs wc -l | sort -rn | awk '$1>300 && $2!="total" {print "OVER LIMIT:", $0}'
# 入口是否含业务(应只装配,行数 < 100
wc -l apps/server/src/app.ts
```
---
## 六、本地 JSON 数据层核心设计(本架构重点)
### 6.1 数据目录可配置
- 环境变量 `APP_DATA_DIR`,默认 `./data`(相对于 `apps/server` 运行目录)。
- 通过 `config/env.ts` 用 zod 读取并校验,输出强类型 `AppConfig`(非 any
- **Docker 挂载**`docker-compose.yml` 将宿主机目录挂载到容器的 `APP_DATA_DIR`,实现持久化与宿主机备份。
```
数据目录(${APP_DATA_DIR}/
├── questions.json # 题库
├── answer-records.json # 作答记录(刷题历史,驱动统计)
├── answer-records-2026-08.json # 归档分片(可选,达阈值后按月切)
├── wrong-questions.json # 错题本
├── mock-exams.json # 模考记录
├── shenlun.json # 申论写作/文章
├── tasks.json # 备考计划任务
├── stats.json # 统计聚合快照
├── settings.json # 偏好设置
└── meta.json # 数据 schema 版本 + 最后写时间(数据资产元信息)
```
> `.gitignore` 应忽略整个 `data/`(数据是用户资产,不入 git但允许用户自行 git 跟踪做版本备份)。
### 6.2 数据文件顶层索引结构
每个实体文件采用**顶层索引 + items 数组**结构(用索引 id→index 便于 O(1) 查找items 为有序记录):
```jsonc
// questions.json
{
"version": 1, // schema 版本
"updatedAt": "2026-08-26T12:00:00.000Z",
"index": { "<question-id>": 0, "<question-id>": 1 }, // id → items 下标
"items": [ /* Question[] 全量 */ ]
}
```
### 6.3 数据形状(由 zod schema 定义,`z.infer` 产出具名类型0 any
> **单一契约源**:领域类型不手写 interface全部由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出。数据文件读入用同一 schema 校验openapi 契约由同一 schema 反推,三者同一来源,永不漂移。
以下以 `packages/shared/src/schemas/question.schema.ts` 为例(示意,完整见 params.md
```ts
import { z } from 'zod';
export const SubjectKeySchema = z.enum(['xingce', 'shenlun']);
export const ModuleKeySchema = z.enum([
'xingce-shuli', 'xingce-panduan', 'xingce-yanyu',
'xingce-changshi', 'xingce-ziliao',
'shenlun-zhuizong', 'shenlun-zonghe', 'shenlun-shenlun',
]);
export const QuestionTypeSchema = z.enum(['single', 'multiple', 'judge', 'blank', 'essay']);
export const QuestionSchema = z.object({
id: z.string().uuid(),
subject: SubjectKeySchema,
module: ModuleKeySchema,
type: QuestionTypeSchema,
stem: z.string().min(1),
options: z.array(z.string()).optional(),
answer: z.union([z.string(), z.array(z.string())]),
analysis: z.string().optional(),
source: z.string().optional(),
difficulty: z.number().int().min(1).max(5).optional(),
tags: z.array(z.string()).default([]),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
aiExplanation: z.string().optional(),
aiConfidence: z.number().min(0).max(1).optional(),
}).openapi('Question'); // 注册为 openapi components/schemas/Question
export type SubjectKey = z.infer<typeof SubjectKeySchema>;
export type ModuleKey = z.infer<typeof ModuleKeySchema>;
export type QuestionType = z.infer<typeof QuestionTypeSchema>;
export type Question = z.infer<typeof QuestionSchema>;
```
其余 7 个实体AnswerRecord / WrongQuestion / MockExam / ShenLunEssay / StudyTask / StatsSnapshot / AppSettings以同样方式定义枚举与字段见 `params.md`
> `z.infer` 直接产出具名类型,**全文件无 any**。请求体 schemaCreate/Update与数据 schema 同文件或同目录定义,均转交给 zod-openapi 注册端点。
### 6.4 原子写入策略(保证数据不损坏)
核心在 `data/store.ts``JsonFileStore` 单例,四个防线:
#### (1) 临时文件 + rename 原子替换
```ts
async atomicWrite(data: JsonFile<T>): Promise<void> {
const dir = dirname(this.filePath);
await mkdir(dir, { recursive: true });
const tmp = join(dir, `.${basename(this.filePath)}.tmp`);
await writeFile(tmp, JSON.stringify(data, null, 2), 'utf8');
await rename(tmp, this.filePath); // 同文件系统内 rename 原子,进程崩溃不会留下半写文件
}
```
> 先写 `.tmp` 再 rename保证任何时候磁盘上要么是旧完整文件要么是新完整文件绝无「写一半」的中间态。
#### (2) 写队列 / 互斥锁(防并发写坏)
```ts
private enqueue<TResult>(task: () => Promise<TResult>): Promise<TResult> {
const run = this.queue.then(task);
this.queue = run.catch(() => undefined); // 队尾吞错,保持链条存活
return run;
}
async save(): Promise<void> {
await this.enqueue(() => this.atomicWrite(this.currentData));
}
```
> 单进程内所有写操作串行入队、逐个执行,杜绝两个请求同时 rename 导致写覆盖或损坏。这是 JSON 数据层最关键的一道防线。
#### (3) 读入 zod 校验(不可信输入收窄)
```ts
function parseWith<T>(schema: ZodType<T>, raw: unknown): T {
return schema.parse(raw); // 返回类型为 T天然收窄无 any
}
```
> 数据来自本地 JSON 文件——文件是可变外部输入可能被手工编辑、被旧版本程序写过、Docker 挂载来源不可控)。读入后必须过 zod schema把「未知 JSON」收窄为「强类型实体」这是「从文件读数据 + 无 any」的关键。
#### (4) 可选防抖批写
刷题高频写(每道题作答一次写 disk用**防抖合并**(如 500ms 内多次写合并为一次落盘)+ **内存立即生效**,降低磁盘 IO
```ts
private scheduleFlush(data: JsonFile<T>): void {
this.currentData = data;
clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => this.save(), this.flushInterval);
}
```
### 6.5 Id 生成与时间字段
- **Id**`crypto.randomUUID()`Node 内置,全局唯一,无需自增/碰撞处理)。
- **时间字段**:所有实体必有 `createdAt` / `updatedAt`ISO 8601`new Date().toISOString()`);写入时 service 统一设置 `updatedAt`
- **时间工具**`utils/date.ts` 提供 `toDateKey(iso): string`yyyy-MM-dd用于 stats/周报聚合)。
### 6.6 数据资产:导出 / 备份 / 还原(接口预留)
数据是「个人长期资产」,架构层预留**一键导出/备份/还原**能力本期仅接口Phase 2 实现):
| 能力 | 接口 | 说明 |
|---|---|---|
| 导出 JSON | `POST /api/v1/data/export` | 把全部实体打包为一个 `gwy-exam-backup-{date}.json`,含 schema 版本 |
| 导出 CSV | `GET /api/v1/data/export?entity=questions` | 按实体导出 CSV错题本/题库→可被 Excel 打开) |
| 备份 | `POST /api/v1/data/backup` | 复制 `APP_DATA_DIR` 到备份子目录(或输出压缩流) |
| 还原 | `POST /api/v1/data/restore` | 读入备份文件,通过 zod 校验后再原子写回各实体文件(先写临时,成功后整体替换) |
> **还原安全性**:备份内容是不可信输入,必须逐实体过 zod schema 校验后再落地;还原前先对当前数据做一次自动备份,坏备份不覆盖好数据。
---
## 七、无 any 的工程化约束(评审铁律)
### 7.1 tsconfigstrict 全家桶)
`tsconfig.base.json` 开满严格选项,**从类型系统层面严禁 any**
```jsonc
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext", // ESM
"moduleResolution": "NodeNext",
"strict": true,
"noImplicitAny": true, // 显式禁止 any
"noImplicitThis": true,
"alwaysStrict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictPropertyInitialization": true,
"exactOptionalPropertyTypes": true, // 可选属性必须显式 undefined杜绝隐式
"useUnknownInCatchVariables": true, // catch 变量为 unknown须窄化
"noUncheckedIndexedAccess": true, // 对象索引访问可能为 undefined
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"declaration": true,
"outDir": "dist"
}
}
```
### 7.2 单一契约源zod schema 驱动类型 + 契约 + 校验
- **领域类型**:由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出前后端共用0 any。不手写 interface避免「手写类型 vs zod schema」两处漂移。
- **OpenAPI 契约**:由 `zod-openapi` 从同一批 zod schema 自动反推生成(见 §二),前端据此生成 TS 类型 + MSW Mock无需手写维护 `openapi.yaml`
- **请求校验**route handler 用同一 zod schema `parse(raw)`,获得收窄后的强类型输入。
三者**同源于 zod schema**,改一个字段只需改 schema 一处,类型与契约自动跟随。
### 7.3 统一响应类型与请求校验
- **统一响应类型** `ApiResponse<T>`泛型0 any
```ts
export interface ApiSuccess<T> {
code: 0;
data: T;
message?: string;
}
export interface ApiFailure {
code: number; // 非 0 错误码
data: null;
message: string; // 人类可读
}
export type ApiResponse<T> = ApiSuccess<T> | ApiFailure;
```
- **请求校验**route handler 内以 zod 校验 body / query / params`parsed` 结果注入局部变量(勿再读原始 req 对象):
```ts
const body = QuestionCreateSchema.parse(req.body); // 收窄为强类型
const list = await questionService.create(body);
res.status(201).json({ code: 0, data: list });
```
### 7.4 错误类型联合(不用 any / 尽量不用 unknown
- **应用错误**`AppError``code``message`,错误码为联合类型(见 `error-codes.ts`)。
- **catch 变量为 unknown**:用窄化守卫,不用 `any` 强转:
```ts
function toAppError(err: unknown): AppError {
if (err instanceof AppError) return err;
return new AppError('INTERNAL_ERROR', 'internal server error');
}
```
---
## 八、API 骨架RESTful`/api/v1/`
统一响应 `ApiResponse<T>`(见 §7.3),分页见 `{ items, total, page, limit, hasMore }`。完整契约由 `zod-openapi` 自动生成(见 `openapi.yaml`Phase 2 前端据其生成类型 + MSW Mock
### 8.1 资源模块清单与方法示例
| 资源 | 端点 | 方法示例 |
|---|---|---|
| 题库 questions | `GET/POST /questions` `GET/PATCH/DELETE /questions/:id` | 列表、详情、创建、改、删;`POST /questions/import` 导入真题;`GET /questions/export` 导出 |
| 刷题 practice | `POST /practice/draw` `POST /practice/submit` | 抽题(按模块/错题优先/随机);提交作答并判定 |
| 错题本 wrong-questions | `GET /wrong-questions` `GET/PATCH /wrong-questions/:id` | 打错因标签;`POST /wrong-questions/:id/resolve` 移除;复习 |
| 模考 mock-exams | `GET/POST /mock-exams` `GET/PATCH/DELETE /mock-exams/:id` | 记录与分模块分析 |
| 申论 shenlun | `GET /shenlun` `POST /shenlun` `PATCH /shenlun/:id` | 写作与自评 |
| 计划 tasks | `GET/POST /tasks` `PATCH /tasks/:id` | 备考计划、打卡、完成 |
| 统计 stats | `GET /stats/overview` `GET /stats/daily` `GET /stats/module` `GET /stats/monthly-report` | 数据中枢聚合(内存聚合) |
| 设置 settings | `GET /settings` `PATCH /settings` | 偏好(含 AI 开关) |
| 数据资产 data | `POST /data/export` `GET /data/export?entity=questions` `POST /data/backup` `POST /data/restore` | 导出/备份/还原(预留) |
### 8.2 REST 语义说明
> 数据底层是 JSON 文件,但 **API 仍用 REST semantic**(资源化 + HTTP 动词 + 版本号),前端据此生成 TS 类型、后端按契约实现。REST 语义与存储介质无关。
---
## 九、可行性验证与风险
### 9.1 核心闭环在 Express + 本地 JSON 下的可行性
| 场景 | 可行性 | 说明 / 防护 |
|---|---|---|
| 录入题库 | 完全可行 | 每道题一次写;写队列串行 + 原子写保护 |
| 分模块刷题(抽题) | 完全可行 | 抽题 = 读内存 items + 按 module/错题优先过滤字段索引O(1)~O(n) |
| 答错自动进错题本 | 完全可行 | Submit → 判定 wrong → 写 answer-record + 写 wrong-question两个文件各一次原子写 |
| 打错因标签 | 完全可行 | 更新 wrong-question一次写 |
| 模考录入 | 完全可行 | 一次写 mock-exam + 可选写 answer-record |
| 统计聚合 | **需防护(可行)** | 见 9.2:用内存聚合,勿每次全量扫描文件 |
### 9.2 性能边界与防护(重点)
| 风险 | 边界 | 防护策略 |
|---|---|---|
| 统计 O(n) 全量扫描 | 每次请求若遍历全部 answer-record随量增线性变慢 | **内存聚合 + 快照**`stats.service` 在内存维护日/模块/错因聚合 Map`AnswerRecord` 写入时同步增量更新;`StatsSnapshot` 定期落盘;查询返回快照/内存,不再全文件扫描。 |
| JSON 文件读入内存 | answer-record 达数万条时加载变慢 | **惰性加载**:仅按需读实体;作答记录超阈值做**归档分片**(按月切 `answer-records-YYYY-MM.json`),历史聚合走快照。 |
| 高频写磁盘 IO | 刷题高峰每道题一次落盘 | **防抖批写**500ms 合并)+ 内存立即生效。 |
| 单文件过大 | 文件越大 rename 越慢 | 实体分文件(每个实体独立)+ 归档分片,单文件控制在合理量级。 |
**量级估算**:单用户年刷题 1-2 万道answer-record 年增 1-2 万条 → 全年约 2-4 万条 JSON每条几百字节约存 10-20MB单文件读入 + 内存聚合均在毫秒级,个人场景完全无压力。**无需更换存储**。
### 9.3 结论:不可行判定
- 对本产品既定功能(录入/刷题/错题本/模考/申论/计划/统计),**没有任何一项是「完全不可行」**。
- 仅「统计聚合」与「大文件写入」是**「当前栈成本高 + 有替代方案」**——替代方案即 §9.2 的内存聚合 + 归档分片,属**本期架构自带防护**,不需换 DB。
- 若未来出现**多设备实时协作 / 数据超百万条 / 需要复杂关系查询**,届时再以 ADR 追加迁移 SQLite/PostgreSQL本期不预留迁移负担。
---
## 十、AI 扩展位(二期 P1本期只留壳不实现
AI 整体在二期,本期架构只为 AI **预留扩展位**,不做任何 AI 实现。
### 10.1 架构预留点
| 项 | 预留方式 | 位置 |
|---|---|---|
| 模块占位 | `services/ai/` 目录 + `providers/` 空实现 | `apps/server/src/services/ai/providers/` |
| Provider 抽象 | `AiProvider` 接口(`explainQuestion` / `giveFeedback` / `ask`),内置空实现 `NoopProvider` | 同上 |
| 数据字段预留 | `Question.aiExplanation / aiConfidence``ShenLunEssay.aiFeedback` 等字段已在 zod schema 中预留(本期可 undefined但不实现产生逻辑 | `packages/shared/src/schemas/` |
| API 命名空间 | 预留 `/api/v1/ai/*`(本期不挂路由,由 Feature Flag 控制,见 §10.2 | `routes/` |
| 异步解耦 | AI 调用设计为非阻塞(返回 `pending` 任务 id后续轮询不阻塞主流程 | service 层约定 |
### 10.2 Feature FlagAI 灰度/总开关)
AI 二期功能用轻量 Feature Flag 控制,避免全量上线风险(不引入第三方服务):
```ts
// settings AppSettings.aiEnabled 作为总开关;后端 checkAIFeature(key) 判定
interface AiFeatureGate {
enabled: boolean; // 全局
rollout?: number; // 0-100可扩展
}
// 若 aiEnabled=false 或 gate 未开,/api/v1/ai/* 返回 404 FEATURE_DISABLED
```
> 本期 `aiEnabled` 恒为 `false``/api/v1/ai/*` 不挂路由、返回未启用错误码。Phase 2 实现 AI 时才打开。
---
## 十一、技术约束清单(汇总,写入 params.md
- 语言:全 TypeScript**全程禁止 any**。
- 后端Express 5.x + Node 24 LTS + ESM。
- 前端Vue 3.5 + Vite 6 + TS 5.8 + Vue Router 4 + Pinia。
- 数据层:本地 JSON 文件(可配置 `APP_DATA_DIR`Docker volume 挂载zod 运行时校验,临时文件 + rename 原子写 + 写队列。
- **单一契约源**:领域类型由 `z.infer` 产出OpenAPI 契约由 `zod-openapi` 自动生成,请求校验用同一 zod schema三者同源同步。
- 类型契约:`packages/shared` 共享 zod schema + `ApiResponse<T>` + 错误码。
- 版本锚定Node 24 LTS / Express 5.2 / Vue 3.5 / Vite 6 / TS 5.8 / zod 4.x / @asteasolutions/zod-to-openapi 8.x安装后回写精确版本
- 图标:前端图标 **由设计/架构阶段锁定一套 SVG 图标库并全局统一,不混用**;架构文档与 API 文档**不出现 emoji**、不采用紫色→粉色渐变、无空洞占位文案。
---
## 十二、机器可读产出物清单
| 文件 | 说明 |
|---|---|
| `architecture.md` | 本文件(核心架构) |
| `openapi.yaml` | OpenAPI 3.0 契约(由 zod-openapi 自动生成;前端据此生成 TS 类型 + MSW Mock |
| `decisions/ADR-001.md` | Express 5 选型 |
| `decisions/ADR-002.md` | Vue3 + Vite 选型 |
| `decisions/ADR-003.md` | 本地 JSON 文件数据层选型 |
| `decisions/ADR-004.md` | 全 TS strict + zod 运行时校验(单一契约源) |
| `decisions/ADR-005.md` | monorepo 布局pnpm workspace |
| `decisions/ADR-006.md` | 垂直切片 + 单一契约源zod + zod-openapi |
| `params.md` | 技术约束清单 + 各资源数据结构说明 |
---
## 十三、端到端验证步骤Phase 2 收尾用)
**改一个字段的完整链路(以修改题目 `stem` 的校验规则为例)**
修改 `packages/shared/src/schemas/question.schema.ts``QuestionSchema`(或 `QuestionCreateSchema`)的 `stem` 校验 → 重新触发 `zod-openapi` 生成契约 → 前端 `orval`/`@hey-api` 据此重新生成 TS 类型 → route handler 用同一 schema 校验、类型与契约已自动跟随。**只需改 schema 一处,类型与契约自动同步。**
1. `pnpm install` → 核对 `package.json` 版本与 §二 锚定表一致。
2. `pnpm --filter <server> build` 通过tsc strict 全家桶,无 any 报错)。
3. `pnpm --filter <server> dev` 启动,`curl /api/v1/settings` 返回 `{code:0,data:{...}}`
4. 走核心闭环:`POST /questions`(录入 1 题)→ `POST /practice/submit`(答错)→ `GET /wrong-questions` 见该题 → `PATCH /wrong-questions/:id` 打错因标签 → `GET /stats/overview` 见聚合。
5. 数据写后 `cat ${APP_DATA_DIR}/questions.json` 校验为合法 JSON原子写未损坏
6. 门禁:`find apps -name '*.ts' | xargs wc -l` 最大文件 ≤ 300`app.ts` < 100
---
*(文档无 emoji、无紫粉渐变、无占位文案。数据形状全部由 zod schema 定义并经 z.infer 产出类型,无 any。*