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

40 KiB
Raw Blame History

备考中枢 · 系统架构设计

版本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 }) 注册端点。
  • OpenApiGeneratorV3V31generateDocument() 一次性产出完整 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/sharedz.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.inferopenapi 契约用 zod-openapi 自动生成,均不自维护另一份定义

门禁命令Phase 2 交付前执行):

# 超 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 为有序记录):

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

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.tsJsonFileStore 单例,四个防线:

(1) 临时文件 + rename 原子替换

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) 写队列 / 互斥锁(防并发写坏)

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 校验(不可信输入收窄)

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

private scheduleFlush(data: JsonFile<T>): void {
  this.currentData = data;
  clearTimeout(this.debounceTimer);
  this.debounceTimer = setTimeout(() => this.save(), this.flushInterval);
}

6.5 Id 生成与时间字段

  • Idcrypto.randomUUID()Node 内置,全局唯一,无需自增/碰撞处理)。
  • 时间字段:所有实体必有 createdAt / updatedAtISO 8601new Date().toISOString());写入时 service 统一设置 updatedAt
  • 时间工具utils/date.ts 提供 toDateKey(iso): stringyyyy-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

{
  "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
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 / paramsparsed 结果注入局部变量(勿再读原始 req 对象):
const body = QuestionCreateSchema.parse(req.body);   // 收窄为强类型
const list = await questionService.create(body);
res.status(201).json({ code: 0, data: list });

7.4 错误类型联合(不用 any / 尽量不用 unknown

  • 应用错误AppErrorcodemessage,错误码为联合类型(见 error-codes.ts)。
  • catch 变量为 unknown:用窄化守卫,不用 any 强转:
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.yamlPhase 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 在内存维护日/模块/错因聚合 MapAnswerRecord 写入时同步增量更新;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 / aiConfidenceShenLunEssay.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 控制,避免全量上线风险(不引入第三方服务):

// 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_DIRDocker 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.tsQuestionSchema(或 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 最大文件 ≤ 300app.ts < 100 行。

(文档无 emoji、无紫粉渐变、无占位文案。数据形状全部由 zod schema 定义并经 z.infer 产出类型,无 any。