# 备考中枢 · 系统架构设计 > 版本: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.x(LTS / Krypton)** | 生产应使用 Active/Maintenance LTS。Node 26 为 Current(未进入 Active LTS),不用于生产。Express 5 要求 Node ≥ 18,24 LTS 满足且有余量。 | | Express | **5.2.x** | Express 5 已是 npm `latest` 默认线(3/2025 切换),v4 已进入 Maintenance(EOL 不早于 2026-10-01)。要求 Node ≥ 18。 | | Vue | **3.5.x** | 官方 create-vue 脚手架默认 3.5.x(Vapor 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 Module(Express 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(成功/失败) │ │ 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 泛型 │ │ 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/ # 运行时数据目录(gitignore,APP_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 解析) │ │ │ │ └── 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 泛型 │ ├── 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→data;service 不 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": { "": 0, "": 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; export type ModuleKey = z.infer; export type QuestionType = z.infer; export type Question = z.infer; ``` 其余 7 个实体(AnswerRecord / WrongQuestion / MockExam / ShenLunEssay / StudyTask / StatsSnapshot / AppSettings)以同样方式定义,枚举与字段见 `params.md`。 > `z.infer` 直接产出具名类型,**全文件无 any**。请求体 schema(Create/Update)与数据 schema 同文件或同目录定义,均转交给 zod-openapi 注册端点。 ### 6.4 原子写入策略(保证数据不损坏) 核心在 `data/store.ts` 的 `JsonFileStore` 单例,四个防线: #### (1) 临时文件 + rename 原子替换 ```ts async atomicWrite(data: JsonFile): Promise { 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(task: () => Promise): Promise { const run = this.queue.then(task); this.queue = run.catch(() => undefined); // 队尾吞错,保持链条存活 return run; } async save(): Promise { await this.enqueue(() => this.atomicWrite(this.currentData)); } ``` > 单进程内所有写操作串行入队、逐个执行,杜绝两个请求同时 rename 导致写覆盖或损坏。这是 JSON 数据层最关键的一道防线。 #### (3) 读入 zod 校验(不可信输入收窄) ```ts function parseWith(schema: ZodType, raw: unknown): T { return schema.parse(raw); // 返回类型为 T,天然收窄,无 any } ``` > 数据来自本地 JSON 文件——文件是可变外部输入(可能被手工编辑、被旧版本程序写过、Docker 挂载来源不可控)。读入后必须过 zod schema,把「未知 JSON」收窄为「强类型实体」,这是「从文件读数据 + 无 any」的关键。 #### (4) 可选防抖批写 刷题高频写(每道题作答一次写 disk)时,用**防抖合并**(如 500ms 内多次写合并为一次落盘)+ **内存立即生效**,降低磁盘 IO: ```ts private scheduleFlush(data: JsonFile): 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 tsconfig(strict 全家桶) `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`(泛型,0 any): ```ts export interface ApiSuccess { code: 0; data: T; message?: string; } export interface ApiFailure { code: number; // 非 0 错误码 data: null; message: string; // 人类可读 } export type ApiResponse = ApiSuccess | 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`(见 §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 Flag(AI 灰度/总开关) 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` + 错误码。 - 版本锚定: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 build` 通过(tsc strict 全家桶,无 any 报错)。 3. `pnpm --filter 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。)*