From 1601508900f90e618b9cd570179ba4a2be877980 Mon Sep 17 00:00:00 2001 From: liyy <18435186204@163.com> Date: Wed, 26 Aug 2026 16:20:55 +0800 Subject: [PATCH] =?UTF-8?q?init:=20=E6=B7=BB=E5=8A=A0=E6=9E=B6=E6=9E=84?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deliverables/architecture/architecture.md | 639 ++++++ .../architecture/decisions/ADR-001-express.md | 31 + .../decisions/ADR-002-vue-vite.md | 31 + .../decisions/ADR-003-json-datastore.md | 49 + .../decisions/ADR-004-ts-strict-zod.md | 46 + .../decisions/ADR-005-monorepo.md | 46 + .../decisions/ADR-006-vertical-slice.md | 42 + deliverables/architecture/openapi.yaml | 1757 +++++++++++++++++ deliverables/architecture/params.md | 161 ++ deliverables/architecture/架构交付说明.md | 88 + 10 files changed, 2890 insertions(+) create mode 100644 deliverables/architecture/architecture.md create mode 100644 deliverables/architecture/decisions/ADR-001-express.md create mode 100644 deliverables/architecture/decisions/ADR-002-vue-vite.md create mode 100644 deliverables/architecture/decisions/ADR-003-json-datastore.md create mode 100644 deliverables/architecture/decisions/ADR-004-ts-strict-zod.md create mode 100644 deliverables/architecture/decisions/ADR-005-monorepo.md create mode 100644 deliverables/architecture/decisions/ADR-006-vertical-slice.md create mode 100644 deliverables/architecture/openapi.yaml create mode 100644 deliverables/architecture/params.md create mode 100644 deliverables/architecture/架构交付说明.md diff --git a/deliverables/architecture/architecture.md b/deliverables/architecture/architecture.md new file mode 100644 index 0000000..cb8c23a --- /dev/null +++ b/deliverables/architecture/architecture.md @@ -0,0 +1,639 @@ +# 备考中枢 · 系统架构设计 + +> 版本: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。)* diff --git a/deliverables/architecture/decisions/ADR-001-express.md b/deliverables/architecture/decisions/ADR-001-express.md new file mode 100644 index 0000000..0e1d6d8 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-001-express.md @@ -0,0 +1,31 @@ +# ADR-001: 使用 Express 5 作为后端框架(Node.js) + +## Status: Accepted (2026-08-26) + +## Background + +「备考中枢」是个人单用户公务员备考数据中枢,需要一组 REST API 对接 Vue3 前端,完成题库录入、刷题、错题本、模考、申论、备考计划、统计聚合。数据量级为个人(1-2 万题/年),无多租户、无高并发、无复杂分布式。用户已拍板后端用 Express。 + +## Decision + +采用 **Express 5.x(Node.js 24 LTS,ESM,全 TypeScript)** 作为后端框架。版本锚定: +- Express `5.2.x`(已是 npm `latest` 默认线,要求 Node ≥ 18) +- Node.js `24.x`(Active LTS) + +选型理由: +1. 技术矩阵定位:Express 在 MVP 后端框架对比中「MVP 速度 5/5、性能 3/5」,正对本项目「简单 CRUD + 全栈 JS」场景(对照 `mvp-stack.md`)。 +2. 生态成熟:129M 周下载、文档完善、中间件丰富、任意 Node 开发可秒上手,学习成本最低。 +3. 与前端同用 TS/JS,全栈统一语言与类型系统,`packages/shared` 可共享领域类型。 +4. 单用户低并发下性能完全够用(瓶颈在 JSON 数据层,非框架本身)。 + +## Consequences + +- 正面:开发速度快、团队熟悉、生态成熟、部署极简(一个 Node 容器)。 +- 负面:Express 5 无内置依赖注入、中间件顺序随代码增长需谨慎;性能不承诺高并发(个人场景无碍)。 +- 权衡:Express 5 是 npm 默认线,新项目直接用;v4 已入 Maintenance(EOL ≥ 2026-10-01),不采用。 + +## Related ADRs +- ADR-003(本地 JSON 数据层) +- ADR-004(全 TS strict + zod) +- ADR-005(monorepo 布局) +- ADR-006(垂直切片 + 单一契约源) diff --git a/deliverables/architecture/decisions/ADR-002-vue-vite.md b/deliverables/architecture/decisions/ADR-002-vue-vite.md new file mode 100644 index 0000000..5c74d05 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-002-vue-vite.md @@ -0,0 +1,31 @@ +# ADR-002: 使用 Vue 3 + Vite 构建响应式前端 + +## Status: Accepted (2026-08-26) + +## Background + +「备考中枢」是响应式 Web(移动优先,碎片双峰刷题;桌面兼顾模考与复盘导出)。前端框架用户已选定 Vue3。需要移动端快、桌面端稳、带图表(数据中枢/模考分析)、且能与后端共享同一套由 zod schema 单一契约源产出的类型契约。 + +## Decision + +采用 **Vue 3.5.x + Vite 6.x + TypeScript 5.8.x**,配 Vue Router 4 + Pinia。版本锚定: +- Vue `3.5.x`(create-vue 脚手架默认;Vapor Mode 为 3.6 RC,不启用) +- Vite `6.x`(Node ≥ 18;本机 Node 24 满足;若装 Vite 7 亦可) +- TypeScript `5.8.x`(strict + any 全禁,配合 vue-tsc) + +选型理由: +1. 技术矩阵定位:Vue 3 在「快速原型/内容站」MVP 速度 5/5、生态 4/5(对照 `mvp-stack.md`)。 +2. 响应式优先:Vue 响应式系统天然适合「刷题状态 / 统计卡刷新」这类高频数据驱动 UI。 +3. 组合式函数(composables)契合「按资源分包、单文件 ≤ 300 行」规范,逻辑组件化。 +4. 前端目录规范(前端专家包 `方案 B`)明确 `components/composables/views/stores/services/types` 分层,与项目架构一致。 +5. 图标锁定一套 SVG 图标库(设计阶段选定并全局统一,不混用;API/架构文档不出现 emoji)。 + +## Consequences + +- 正面:响应式、生态成熟、与 TS 结合良好、脚手架即含类型检查。 +- 负面:Vue 3.5 尚在 Vapor Mode 前,SSR 能力弱(本项目为纯客户端 SPA,无需 SSR)。 +- 权衡:不为「未来可能的多端」预置 Taro/uni-app(产品已明确不做原生 App、不做多端,不必提前押注)。 + +## Related ADRs +- ADR-005(monorepo 布局,前端经 `packages/shared` 共享类型) +- ADR-004(全 TS strict + zod) diff --git a/deliverables/architecture/decisions/ADR-003-json-datastore.md b/deliverables/architecture/decisions/ADR-003-json-datastore.md new file mode 100644 index 0000000..e641384 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-003-json-datastore.md @@ -0,0 +1,49 @@ +# ADR-003: 使用本地 JSON 文件作为数据层(非 SQLite) + +## Status: Accepted (2026-08-26) + +## Background + +「备考中枢」是**个人单用户、自用型**工具,数据是用户的长期备考资产(题库/错题本/作答记录/模考/申论/计划/统计)。用户已明确选 **JSON 文件存储**(非 SQLite)。需要数据可读、可迁移、可备份,并支持 Docker volume 挂载持久化。 + +## Decision + +采用**本地 JSON 文件**作为数据层:每实体一个 JSON 文件(`questions.json` / `wrong-questions.json` / `mock-exams.json` / `tasks.json` / `shenlun.json` / `answer-records.json` / `stats.json` / `settings.json` / `meta.json`),存放在可配置数据目录 `APP_DATA_DIR`(Docker volume 挂载点)。 + +核心设计(见 architecture.md §六): +1. **每实体一个文件 + 顶层索引结构**:`{ version, updatedAt, index: {id→下标}, items: [] }`。 +2. **数据形状**:全部由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 定义,经 `z.infer` 产出强类型,无 any;状态用字符串字面量联合。 +3. **原子写入**:临时文件 + `fs.rename` 原子替换(同文件系统 rename 原子)。 +4. **写队列串行**:单进程内所有写操作入 Promise 队列逐条执行,杜绝并发写坏文件。 +5. **防抖批写**:高频写(刷题每道题)合并落盘,内存立即生效。 +6. **zod 运行时校验**:文件是可变外部输入,读入后过 schema 收窄为强类型实体。 +7. **统计用内存聚合 + 快照**:不每次全量扫描文件。 +8. **数据资产能力**:预留一键导出 JSON/CSV + 备份/还原接口。 + +### 为什么对个人工具合理(正面) +| 维度 | 说明 | +|---|---| +| 单机低运维 | 零安装、零连接、零迁移脚本,Docker 一个容器即用 | +| 数据资产可读可迁移 | 纯文件可 cat/jq 直读、可 git 跟踪、可一键备份,跨设备无锁定 | +| 开发快 | 无 ORM/迁移/连接池,`data/store.ts` 单点 fs 读写,MVP 工作量最小 | +| DevOps 极简 | Docker volume 挂载即持久化,备份即拷贝目录 | + +### 代价与规避(已正视) +| 代价 | 规避 | +|---|---| +| 并发写 | 写队列串行(互斥) | +| 写入非原子 | 临时文件 + rename 原子替换 | +| 无事务 | 单文件原子 + 组合操作分解为最小写单元 + 统计可重算(不强一致) | +| 体积增长 | 按需惰性加载 + 作答记录归档分片(按月切文件) | +| 统计 O(n) | 内存聚合 + 快照持久化 | + +## Consequences + +- 正面:数据资产感强、运维极简、开发快、零基础设施成本。 +- 负面:无事务、无查询索引、并发写入需手动防护、数据量大后需归档分片。 +- 权衡:对个人单用户量级(年 1-2 万题,约 10-20 MB JSON)完全无压力。**本期不需要 SQLite/PostgreSQL**。若未来出现多设备协作 / 超百万条 / 复杂关系查询,再以新 ADR 追加迁移(本期不预留迁移负担)。 + +## Related ADRs +- ADR-001(Express 5) +- ADR-004(zod 运行时校验——数据来自 JSON 文件的关键保障) +- ADR-005(monorepo) diff --git a/deliverables/architecture/decisions/ADR-004-ts-strict-zod.md b/deliverables/architecture/decisions/ADR-004-ts-strict-zod.md new file mode 100644 index 0000000..8245096 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-004-ts-strict-zod.md @@ -0,0 +1,46 @@ +# ADR-004: 全 TypeScript strict + zod 运行时校验,全程禁止 any + +## Status: Accepted (2026-08-26) + +## Background + +用户硬性要求:全 TypeScript,**全程禁止 any**(评审铁律)。同时本项目数据来自**本地 JSON 文件**——文件是可变的外部输入(可能被手工编辑、被旧版本程序写过、Docker 挂载来源不可控),若不运行时校验,读入的数据无法保证符合领域类型。 + +## Decision + +1. **tsconfig 全开严格选项**(`strict` 全家桶): + `strict / noImplicitAny / strictNullChecks / strictFunctionTypes / exactOptionalPropertyTypes / useUnknownInCatchVariables / noUncheckedIndexedAccess / noImplicitReturns / noUnusedLocals...` +2. **类型定义集中 + 单一契约源**:领域类型由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出(不手写 interface),前后端共用;统一响应 `ApiResponse = ApiSuccess | ApiFailure`(泛型,0 any)。 +3. **错误用联合类型**:`AppError` 带错误码联合 + message;`catch` 变量为 `unknown`,用类型守卫窄化,**不 any 强转**。 +4. **zod 运行时校验获得类型收窄**:数据来自 JSON 文件,用 `zod` schema `parse(raw)`(返回类型即 T,天然收窄)。这是「从文件读数据 + 无 any」的最优实践。`z.infer` 产出具名类型。 +5. **OpenAPI 契约同源**:openapi.yaml 由 `zod-openapi` 从同一批 zod schema 自动反推生成,前端据此生成 TS 类型,无需另维护一份手写契约。 + +### zod 示例(纯 TS,无 any) +```ts +import { z } from 'zod'; +const ModuleKeySchema = z.enum(['xingce-shuli', 'xingce-panduan', /** ... */]); +export type ModuleKey = z.infer; + +const QuestionSchema = z.object({ + id: z.string().uuid(), + subject: z.enum(['xingce', 'shenlun']), + module: ModuleKeySchema, + type: z.enum(['single', 'multiple', 'judge', 'blank', 'essay']), + stem: z.string().min(1), + options: z.array(z.string()).optional(), + answer: z.union([z.string(), z.array(z.string())]), + // ... 全部显式,无 any +}).openapi('Question'); +export type Question = z.infer; +``` + +## Consequences + +- 正面:`any` 从类型系统层面被禁止;zod 让「不可信的 JSON 输入」在数据层就被收窄为强类型实体,后续代码零断言;前后端共享同一 schema,契约一致;领域类型、OpenAPI 契约、请求校验三者同源于 zod schema,改一处全链跟随,杜绝漂移。 +- 负面:引入 zod + zod-openapi 依赖;strict 选项与单一契约源会放大生成代码的报错面(但这是隔离性投资,越快暴露越好,避免把 `unknown` 漏到业务层)。 +- 权衡:以 zod 作为**唯一**类型与契约来源,不另维护手写 interface 或手写 openapi.yaml,消除「type 与 schema 两处对不上」的隐患。 + +## Related ADRs +- ADR-003(JSON 数据层——zod 是其关键保障) +- ADR-006(垂直切片 + 单一契约源——zod schema 驱动类型与契约) +- ADR-001 / ADR-002(前后端均全 TS) diff --git a/deliverables/architecture/decisions/ADR-005-monorepo.md b/deliverables/architecture/decisions/ADR-005-monorepo.md new file mode 100644 index 0000000..981a5a3 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-005-monorepo.md @@ -0,0 +1,46 @@ +# ADR-005: Monorepo 布局(pnpm workspace,apps/server + apps/web + packages/shared) + +## Status: Accepted (2026-08-26) + +## Background + +后端(Express)与前端(Vue3)同用 TypeScript,且有大量共享的领域类型(由 zod schema 经 `z.infer` 产出)、zod 数据 schema、统一响应 `ApiResponse`、错误码、枚举。若前后端各自复制一份类型定义,一旦模型变更会出现「同一模型多处漂移」,违背 spec-as-contract(契约应单点可寻址)。 + +## Decision + +采用 **pnpm workspace 单仓多包**: + +``` +gwy-exam/ +├── pnpm-workspace.yaml +├── package.json # 仅脚本 + workspace,不装业务依赖 +├── tsconfig.base.json # 共享 strict 编译基座 +├── apps/ +│ ├── server/ # Express 5 + TS + ESM +│ └── web/ # Vue3 + Vite + TS +└── packages/ + └── shared/ # 领域类型 + zod schema + ApiResponse + 枚举 + 常量 +``` + +类型契约、zod schema、`ApiResponse`、错误码联合全部放 `packages/shared`,`apps/server` 与 `apps/web` 通过 workspace 引用共享。 + +### 为什么选 monorepo 而非独立仓 +1. 前后端同 JS/TS,`packages/shared` 是天然共享层,独立仓需额外发布/维护两份。 +2. 契约类型单点维护,防漂移(spec-as-contract:契约可寻址、可审计)。 +3. 一次 `pnpm install`、一次 CI,开发与验收路径单一。 + +### 分层依赖(对照 code-organization.md) +- `apps/server` 依赖 `packages/shared`(向下)。 +- `apps/web` 依赖 `packages/shared`(向下)。 +- `packages/shared` **不依赖任何 app**,保持纯净、可被任何一方 import 而无环。 + +## Consequences + +- 正面:类型单一来源、契约一致、前后端并行开发不踩、一次安装。 +- 负面:单仓体积稍大;`packages/shared` 须保持无业务、无 app 依赖(否则环),需纪律约束。 +- 权衡:个人工具规模下,monorepo 的成本(配置 pnpm workspace + 共享 tsconfig)远低于收益(类型一致性)。若未来拆出多应用,可无损演进。 + +## Related ADRs +- ADR-001(Express)/ ADR-002(Vue+Vite) +- ADR-004(全 TS strict — 与 shared 类型强绑定) +- ADR-006(垂直切片 — shared 作为单一契约源的承载层) diff --git a/deliverables/architecture/decisions/ADR-006-vertical-slice.md b/deliverables/architecture/decisions/ADR-006-vertical-slice.md new file mode 100644 index 0000000..81bfe85 --- /dev/null +++ b/deliverables/architecture/decisions/ADR-006-vertical-slice.md @@ -0,0 +1,42 @@ +# ADR-006: 垂直切片 + 单一契约源(zod + zod-openapi) + +## Status: Accepted (2026-08-26) + +## Background + +「备考中枢」是**个人单用户、自用型**备考数据中枢,主体为对 8 个领域实体的 CRUD + 少量跨实体组合业务(抽题、判定、错题入本、增量聚合、备份还原)。数据量级小、无多租户、无高并发。产品希望「改一个接口」的落点尽量集中,避免在多个目录间来回跳动;同时类型契约、请求校验、对外 API 文档三处必须保持一致,不出现「同一模型多处定义漂移」。 + +前端(Vue3)与后端(Express)同用 TypeScript,且依赖同一批领域类型;数据来自本地 JSON 文件(不可信输入),必须做运行时校验。 + +## Decision + +采用**垂直切片**组织方式,并采用**单一契约源(zod + zod-openapi)**: + +1. **垂直切片(3 层,依赖只向下)**: + - **Handler 层(`routes/*.routes.ts`)**:路由 + zod 请求校验 + 调 service + 组装 `ApiResponse` 集中在一个 route 文件。请求校验的 zod schema 从 `packages/shared` 引入。 + - **Service 层(`services/*.service.ts`)**:承载**跨实体复杂业务**(抽题、判定、错题入本、增量聚合、备份还原)。这一层独立保留,是因为这些组合操作跨多个实体、有规则与编排逻辑,不能塞进 handler;同时它不 import HTTP 对象、不返回 HTTP 响应,只返回业务结果或抛业务异常。 + - **数据访问 + 领域类型层(`data/` + `packages/shared/src/schemas/`)**:`data/store.ts` 单文件存取(读 JSON + zod 校验 + 临时文件原子 rename + 写队列串行 + 惰性缓存),`data/file-map.ts` 解析数据路径;领域类型由 zod `z.infer` 产出。个人单机 JSON 项目不再拆分 repository/store 两层。 + - 依赖方向:route → service → data;service 不 import req/res;跨资源协作走对方 service 接口。 + +2. **单一契约源(zod + zod-openapi)**: + - 领域类型由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出,**不手写 interface**。 + - OpenAPI 契约由 `@asteasolutions/zod-to-openapi` 从同一批 zod schema **自动反推生成**(`OpenAPIRegistry.register` / `registerPath` + `OpenApiGeneratorV3.generateDocument`),**不手写维护 openapi.yaml**。 + - 请求校验用同一 zod schema `schema.parse(raw)`。 + - 三者同源于 zod schema:改一个字段只需改 schema 一处,类型、契约、校验自动跟随。 + +### 为何采用该架构 +| 维度 | 说明 | +|---|---| +| 改接口落点少 | 一个资源 = 一个 route 文件 + 一个 service 文件;增删改一个端点只落在一个 route handler 内(校验 + 编排 + 响应组装同文件),跨实体逻辑只落在对应 service。 | +| 契约不漂移 | 类型、openapi 契约、请求校验三处同源于 zod schema;改一处全链跟随,无需维护多份定义,杜绝「手写 interface 与 zod 不一致」「契约过期」两类漂移。 | +| 契合个人单机项目 | JSON 数据层无需 repository/store 两层抽象;单文件存取(`data/store.ts`)+ 原子写 + 写队列足够覆盖单用户量级,剥离掉纯仪式性分层,减少文件跳转。 | +| service 层承载跨实体业务 | 抽题、判定、错题入本、增量聚合、备份还原是跨多个实体的组合业务,有真实规则与编排逻辑,独立成层以保持 handler 只做 HTTP 编排、数据层只做存取。 | + +## Consequences + +- 正面:改一个接口的落点集中(多在一个 route 文件 + 一个 service 文件);类型/契约/校验三处永不漂移;剥离出与个人 JSON 项目不匹配的过度分层,文件组织更贴近实际改动路径;`z.infer` 产出具名类型,全链路 0 any。 +- 负面:route handler 内同时承担校验、编排、响应组装,需以「单文件 ≤ 300 行 + 单一职责」约束防止 handler 膨胀;跨实体业务若被拆分过细仍可能跨文件,属正常,核心在「改单接口」这条主路径的落点集中;须在入口只调用一次 `extendZodWithOpenApi(z)`,并让 `packages/shared` 保持无 app 依赖、纯净可复用。 +- 权衡:垂直切片用「路由此纵切」替代「按层横堆」,换取更贴近单接口改动路径的文件组织;单一契约源用「zod 一处定义」替代「手写契约 + 手写类型」的多份维护,换取契约一致性。对个人单机项目,二者的复杂度增量(zod-openapi 引入)远低于收益(契约稳定 + 改动落点少)。 + +## Related ADRs +- ADR-001(Express 5)/ ADR-002(Vue3 + Vite)/ ADR-003(本地 JSON 数据层)/ ADR-004(全 TS strict + zod)/ ADR-005(monorepo) diff --git a/deliverables/architecture/openapi.yaml b/deliverables/architecture/openapi.yaml new file mode 100644 index 0000000..f17a5fb --- /dev/null +++ b/deliverables/architecture/openapi.yaml @@ -0,0 +1,1757 @@ +# 本文件由 @asteasolutions/zod-to-openapi 从 packages/shared/src/schemas/*.schema.ts 的 zod schema 自动反推生成。 +# 若修改字段/端点,请修改 zod schema 后重新运行 openapi 生成脚本(见 architecture.md §二 / §5.1),勿手改本文件。 +# 契约与 zod schema、请求运行时校验、z.infer 类型三者同源,永不失同步。 +openapi: 3.0.3 +info: + title: 备考中枢 API(公务员考试备考数据中枢) + version: 1.0.0 + description: > + 个人单用户公务员备考工具 REST API。数据底层为本地 JSON 文件,但对外保持 REST + semantic(资源化 + HTTP 动词 + 版本号)。统一响应 ApiResponse,code=0 成功, + code!=0 失败。全 TypeScript,无 any。本契约由 zod-openapi 从 zod schema 自动生成, + 前端据此生成 TS 类型(orval / @hey-api/openapi-ts)与 MSW Mock。 +servers: + - url: http://localhost:3000 + description: 本地开发 +tags: + - name: questions + description: 题库(真题录入 / 管理 / 导入 / 导出) + - name: practice + description: 刷题(抽题 / 提交作答判定) + - name: wrong-questions + description: 错题本(自动入本 / 错因标签 / 恢复) + - name: mock-exams + description: 模考记录与分模块分析 + - name: shenlun + description: 申论写作 / 自评 + - name: tasks + description: 备考计划任务 / 打卡 + - name: stats + description: 数据中枢统计聚合 + - name: settings + description: 偏好设置(含 AI 开关) + - name: data + description: 数据资产(导出 / 备份 / 还原) +paths: + # ============ 题库 Questions ============ + /api/v1/questions: + get: + tags: [questions] + summary: 题库列表(支持分页 / 按模块 / 关键字筛选) + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: module + in: query + schema: + $ref: '#/components/schemas/ModuleKey' + - name: subject + in: query + schema: + $ref: '#/components/schemas/SubjectKey' + - name: q + in: query + schema: + type: string + - name: sort + in: query + schema: + type: string + enum: [created_at, difficulty] + description: 排序字段 + - name: order + in: query + schema: + type: string + enum: [asc, desc] + responses: + '200': + description: 分页列表 + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionListResponse' + '400': + $ref: '#/components/responses/BadRequest' + post: + tags: [questions] + summary: 录入题(单题) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionCreate' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionResponse' + '400': + $ref: '#/components/responses/BadRequest' + '422': + $ref: '#/components/responses/ValidationError' + /api/v1/questions/import: + post: + tags: [questions] + summary: 批量导入真题(数组) + requestBody: + required: true + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/QuestionCreate' + responses: + '201': + description: 导入结果 + content: + application/json: + schema: + $ref: '#/components/schemas/ImportResponse' + '422': + $ref: '#/components/responses/ValidationError' + /api/v1/questions/export: + get: + tags: [questions] + summary: 导出题库(JSON 全量) + responses: + '200': + description: 导出的题库 JSON + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Question' + /api/v1/questions/{id}: + get: + tags: [questions] + summary: 题目详情 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 题目详情 + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionResponse' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [questions] + summary: 更新题目 + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/QuestionResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [questions] + summary: 删除题目 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '404': + $ref: '#/components/responses/NotFound' + + # ============ 刷题 Practice ============ + /api/v1/practice/draw: + post: + tags: [practice] + summary: 抽题(按模块 / 错题优先 / 随机) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DrawRequest' + responses: + '200': + description: 抽题结果(题目列表) + content: + application/json: + schema: + $ref: '#/components/schemas/DrawResponse' + '400': + $ref: '#/components/responses/BadRequest' + /api/v1/practice/submit: + post: + tags: [practice] + summary: 提交作答并判定(答错自动进错题本) + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitAnswerRequest' + responses: + '201': + description: 判定结果(含是否入错题本) + content: + application/json: + schema: + $ref: '#/components/schemas/SubmitAnswerResponse' + '400': + $ref: '#/components/responses/BadRequest' + '422': + $ref: '#/components/responses/ValidationError' + + # ============ 错题本 WrongQuestions ============ + /api/v1/wrong-questions: + get: + tags: [wrong-questions] + summary: 错题本列表(按模块 / 状态 / 错因筛选) + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: module + in: query + schema: + $ref: '#/components/schemas/ModuleKey' + - name: status + in: query + schema: + $ref: '#/components/schemas/WrongQuestionStatus' + - name: reason + in: query + schema: + $ref: '#/components/schemas/WrongReason' + responses: + '200': + description: 错题本分页 + content: + application/json: + schema: + $ref: '#/components/schemas/WrongQuestionListResponse' + /api/v1/wrong-questions/{id}: + get: + tags: [wrong-questions] + summary: 错题详情 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 错题详情 + content: + application/json: + schema: + $ref: '#/components/schemas/WrongQuestionResponse' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [wrong-questions] + summary: 更新错题(打错因标签 / 批注 / 状态) + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WrongQuestionUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/WrongQuestionResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + /api/v1/wrong-questions/{id}/resolve: + post: + tags: [wrong-questions] + summary: 标记错题已掌握(移除待复习清单) + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 更新状态为 mastered + content: + application/json: + schema: + $ref: '#/components/schemas/WrongQuestionResponse' + '404': + $ref: '#/components/responses/NotFound' + + # ============ 模考 MockExams ============ + /api/v1/mock-exams: + get: + tags: [mock-exams] + summary: 模考列表 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: examType + in: query + schema: + $ref: '#/components/schemas/ExamType' + responses: + '200': + description: 模考分页 + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamListResponse' + post: + tags: [mock-exams] + summary: 录入模考成绩 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamCreate' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamResponse' + '422': + $ref: '#/components/responses/ValidationError' + /api/v1/mock-exams/{id}: + get: + tags: [mock-exams] + summary: 模考详情(含分模块分析) + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 模考详情 + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamResponse' + '404': + $ref: '#/components/responses/NotFound' + patch: + tags: [mock-exams] + summary: 更新模考记录 + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/MockExamResponse' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [mock-exams] + summary: 删除模考记录 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '404': + $ref: '#/components/responses/NotFound' + + # ============ 申论 ShenLun ============ + /api/v1/shenlun: + get: + tags: [shenlun] + summary: 申论列表 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: module + in: query + schema: + $ref: '#/components/schemas/ShenLunModuleKey' + responses: + '200': + description: 申论分页 + content: + application/json: + schema: + $ref: '#/components/schemas/ShenLunListResponse' + post: + tags: [shenlun] + summary: 新建申论写作 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShenLunCreate' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ShenLunResponse' + '422': + $ref: '#/components/responses/ValidationError' + /api/v1/shenlun/{id}: + patch: + tags: [shenlun] + summary: 更新申论(自评 / 正文) + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/ShenLunUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/ShenLunResponse' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [shenlun] + summary: 删除申论 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '404': + $ref: '#/components/responses/NotFound' + + # ============ 备考计划 Tasks ============ + /api/v1/tasks: + get: + tags: [tasks] + summary: 任务列表 + parameters: + - $ref: '#/components/parameters/Page' + - $ref: '#/components/parameters/Limit' + - name: status + in: query + schema: + $ref: '#/components/schemas/TaskStatus' + - name: planDate + in: query + schema: + type: string + format: date + responses: + '200': + description: 任务分页 + content: + application/json: + schema: + $ref: '#/components/schemas/TaskListResponse' + post: + tags: [tasks] + summary: 创建任务 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TaskCreate' + responses: + '201': + description: 创建成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '422': + $ref: '#/components/responses/ValidationError' + /api/v1/tasks/{id}: + patch: + tags: [tasks] + summary: 更新任务(状态 / 完成量 / 打卡) + parameters: + - $ref: '#/components/parameters/Id' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TaskUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TaskResponse' + '400': + $ref: '#/components/responses/BadRequest' + '404': + $ref: '#/components/responses/NotFound' + delete: + tags: [tasks] + summary: 删除任务 + parameters: + - $ref: '#/components/parameters/Id' + responses: + '200': + description: 删除成功 + content: + application/json: + schema: + $ref: '#/components/schemas/DeleteResponse' + '404': + $ref: '#/components/responses/NotFound' + + # ============ 统计 Stats ============ + /api/v1/stats/overview: + get: + tags: [stats] + summary: 数据中枢总览(总答题数 / 正确率 / 错因分布 / 连续打卡) + responses: + '200': + description: 统计总览 + content: + application/json: + schema: + $ref: '#/components/schemas/StatsOverviewResponse' + /api/v1/stats/daily: + get: + tags: [stats] + summary: 日粒度趋势(近 N 天) + parameters: + - name: days + in: query + schema: + type: integer + minimum: 1 + maximum: 365 + default: 30 + responses: + '200': + description: 日趋势 + content: + application/json: + schema: + $ref: '#/components/schemas/StatsDailyResponse' + /api/v1/stats/module: + get: + tags: [stats] + summary: 分模块表现(考点×错因归因输入) + responses: + '200': + description: 模块统计 + content: + application/json: + schema: + $ref: '#/components/schemas/StatsModuleResponse' + /api/v1/stats/monthly-report: + get: + tags: [stats] + summary: 月报复盘 + parameters: + - name: month + in: query + schema: + type: string + format: yyyy-MM + responses: + '200': + description: 月报 + content: + application/json: + schema: + $ref: '#/components/schemas/StatsMonthlyResponse' + + # ============ 设置 Settings ============ + /api/v1/settings: + get: + tags: [settings] + summary: 获取偏好设置 + responses: + '200': + description: 设置 + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' + patch: + tags: [settings] + summary: 更新偏好设置 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsUpdate' + responses: + '200': + description: 更新成功 + content: + application/json: + schema: + $ref: '#/components/schemas/SettingsResponse' + '422': + $ref: '#/components/responses/ValidationError' + + # ============ 数据资产 Data ============ + /api/v1/data/export: + post: + tags: [data] + summary: 导出全部数据为备份 JSON + responses: + '200': + description: 备份 JSON(含 schema 版本) + content: + application/json: + schema: + $ref: '#/components/schemas/BackupPayload' + get: + tags: [data] + summary: 导出指定实体为 CSV + parameters: + - name: entity + in: query + required: true + schema: + type: string + enum: [questions, wrong-questions, mock-exams, shenlun, tasks] + responses: + '200': + description: CSV 文本 + content: + text/csv: + schema: + type: string + /api/v1/data/backup: + post: + tags: [data] + summary: 创建备份副本 + responses: + '200': + description: 备份结果 + content: + application/json: + schema: + $ref: '#/components/schemas/BackupResult' + /api/v1/data/restore: + post: + tags: [data] + summary: 从备份还原 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BackupPayload' + responses: + '200': + description: 还原成功 + content: + application/json: + schema: + $ref: '#/components/schemas/RestoreResult' + '400': + $ref: '#/components/responses/BadRequest' + '422': + $ref: '#/components/responses/ValidationError' + +components: + parameters: + Id: + name: id + in: path + required: true + description: 实体 id(UUID) + schema: + type: string + format: uuid + Page: + name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + Limit: + name: limit + in: query + schema: + type: integer + minimum: 1 + maximum: 200 + default: 20 + + schemas: + # ---------- 枚举 ---------- + SubjectKey: + type: string + enum: [xingce, shenlun] + ModuleKey: + type: string + enum: + - xingce-shuli + - xingce-panduan + - xingce-yanyu + - xingce-changshi + - xingce-ziliao + - shenlun-zhuizong + - shenlun-zonghe + - shenlun-shenlun + ShenLunModuleKey: + type: string + enum: [shenlun-zhuizong, shenlun-zonghe, shenlun-shenlun] + QuestionType: + type: string + enum: [single, multiple, judge, blank, essay] + Correctness: + type: string + enum: [correct, wrong, partial, blank] + WrongReason: + type: string + enum: + - knowledge-gap + - concept-confusion + - careless + - time-pressure + - method-unfamiliar + - calculation-error + - reading-error + WrongQuestionStatus: + type: string + enum: [open, resolved, mastered] + ExamType: + type: string + enum: [national, province, self] + TaskStatus: + type: string + enum: [todo, doing, done, skipped] + TaskRecurrence: + type: string + enum: [once, daily, weekly] + DayOfWeek: + type: string + enum: [mon, tue, wed, thu, fri, sat, sun] + + # ---------- Question ---------- + Question: + type: object + required: [id, subject, module, type, stem, answer, tags, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + subject: + $ref: '#/components/schemas/SubjectKey' + module: + $ref: '#/components/schemas/ModuleKey' + type: + $ref: '#/components/schemas/QuestionType' + stem: + type: string + minLength: 1 + options: + type: array + items: + type: string + answer: + oneOf: + - type: string + - type: array + items: + type: string + analysis: + type: string + source: + type: string + difficulty: + type: integer + minimum: 1 + maximum: 5 + tags: + type: array + items: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + aiExplanation: + type: string + aiConfidence: + type: number + minimum: 0 + maximum: 1 + QuestionCreate: + allOf: + - $ref: '#/components/schemas/Question' + - type: object + properties: + id: + type: string + format: uuid + description: 可省略,服务端生成 + QuestionUpdate: + type: object + minProperties: 1 + properties: + stem: + type: string + minLength: 1 + options: + type: array + items: + type: string + answer: + oneOf: + - type: string + - type: array + items: + type: string + analysis: + type: string + source: + type: string + difficulty: + type: integer + minimum: 1 + maximum: 5 + tags: + type: array + items: + type: string + module: + $ref: '#/components/schemas/ModuleKey' + QuestionResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/Question' + QuestionListResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/PageData-Question' + + # ---------- Practice ---------- + DrawRequest: + type: object + properties: + subject: + $ref: '#/components/schemas/SubjectKey' + module: + $ref: '#/components/schemas/ModuleKey' + count: + type: integer + minimum: 1 + maximum: 50 + default: 10 + strategy: + type: string + enum: [random, module, wrong-first] + default: module + DrawResponse: + type: object + properties: + questions: + type: array + items: + $ref: '#/components/schemas/Question' + SubmitAnswerRequest: + type: object + required: [questionId, userAnswer, source] + properties: + questionId: + type: string + format: uuid + userAnswer: + oneOf: + - type: string + - type: array + items: + type: string + source: + type: string + enum: [practice, mock-exam] + tookMs: + type: integer + minimum: 0 + wrongReasons: + type: array + items: + $ref: '#/components/schemas/WrongReason' + SubmitAnswerResponse: + type: object + properties: + questionId: + type: string + format: uuid + correctness: + $ref: '#/components/schemas/Correctness' + isWrong: + type: boolean + correctAnswer: + oneOf: + - type: string + - type: array + items: + type: string + enteredWrongBook: + type: boolean + wrongQuestionId: + type: string + format: uuid + nullable: true + + # ---------- AnswerRecord(作答记录,驱动统计)---------- + AnswerRecord: + type: object + required: [id, questionId, module, correctness, isWrong, userAnswer, source, practiceDate, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + questionId: + type: string + format: uuid + module: + $ref: '#/components/schemas/ModuleKey' + correctness: + $ref: '#/components/schemas/Correctness' + isWrong: + type: boolean + userAnswer: + oneOf: + - type: string + - type: array + items: + type: string + wrongReasons: + type: array + items: + $ref: '#/components/schemas/WrongReason' + tookMs: + type: integer + minimum: 0 + source: + type: string + enum: [practice, mock-exam] + practiceDate: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + # ---------- WrongQuestion ---------- + WrongQuestion: + type: object + required: [id, questionId, module, wrongReasons, wrongCount, lastWrongAt, reviewCount, status, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + questionId: + type: string + format: uuid + module: + $ref: '#/components/schemas/ModuleKey' + wrongReasons: + type: array + items: + $ref: '#/components/schemas/WrongReason' + wrongCount: + type: integer + minimum: 1 + lastWrongAt: + type: string + format: date-time + reviewCount: + type: integer + minimum: 0 + status: + $ref: '#/components/schemas/WrongQuestionStatus' + note: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + WrongQuestionUpdate: + type: object + minProperties: 1 + properties: + wrongReasons: + type: array + items: + $ref: '#/components/schemas/WrongReason' + note: + type: string + status: + $ref: '#/components/schemas/WrongQuestionStatus' + reviewCount: + type: integer + minimum: 0 + WrongQuestionResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/WrongQuestion' + WrongQuestionListResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/PageData-WrongQuestion' + + # ---------- MockExam ---------- + MockExam: + type: object + required: [id, title, examType, fullScore, score, durationMin, examDate, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + title: + type: string + examType: + $ref: '#/components/schemas/ExamType' + fullScore: + type: number + score: + type: number + rank: + type: integer + durationMin: + type: integer + minimum: 0 + moduleScores: + type: object + additionalProperties: + type: number + moduleCorrectRate: + type: object + additionalProperties: + type: number + examDate: + type: string + format: date + note: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + MockExamCreate: + type: object + required: [title, examType, fullScore, score, durationMin, examDate] + properties: + title: + type: string + examType: + $ref: '#/components/schemas/ExamType' + fullScore: + type: number + score: + type: number + rank: + type: integer + durationMin: + type: integer + minimum: 0 + moduleScores: + type: object + additionalProperties: + type: number + moduleCorrectRate: + type: object + additionalProperties: + type: number + examDate: + type: string + format: date + note: + type: string + MockExamUpdate: + type: object + minProperties: 1 + properties: + title: + type: string + score: + type: number + rank: + type: integer + durationMin: + type: integer + minimum: 0 + moduleScores: + type: object + additionalProperties: + type: number + moduleCorrectRate: + type: object + additionalProperties: + type: number + note: + type: string + MockExamResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/MockExam' + MockExamListResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/PageData-MockExam' + + # ---------- ShenLun ---------- + ShenLunEssay: + type: object + required: [id, topic, module, content, wordCount, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + examId: + type: string + format: uuid + topic: + type: string + module: + $ref: '#/components/schemas/ShenLunModuleKey' + content: + type: string + wordCount: + type: integer + minimum: 0 + durationMin: + type: integer + minimum: 0 + selfRating: + type: integer + minimum: 1 + maximum: 5 + aiFeedback: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ShenLunCreate: + type: object + required: [topic, module, content] + properties: + examId: + type: string + format: uuid + topic: + type: string + module: + $ref: '#/components/schemas/ShenLunModuleKey' + content: + type: string + durationMin: + type: integer + minimum: 0 + selfRating: + type: integer + minimum: 1 + maximum: 5 + ShenLunUpdate: + type: object + minProperties: 1 + properties: + content: + type: string + durationMin: + type: integer + minimum: 0 + selfRating: + type: integer + minimum: 1 + maximum: 5 + ShenLunResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/ShenLunEssay' + ShenLunListResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/PageData-ShenLunEssay' + + # ---------- Task ---------- + StudyTask: + type: object + required: [id, title, planDate, status, recurrence, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + title: + type: string + module: + $ref: '#/components/schemas/ModuleKey' + planDate: + type: string + format: date + status: + $ref: '#/components/schemas/TaskStatus' + recurrence: + $ref: '#/components/schemas/TaskRecurrence' + targetCount: + type: integer + minimum: 0 + completedCount: + type: integer + minimum: 0 + note: + type: string + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + TaskCreate: + type: object + required: [title, planDate, recurrence] + properties: + title: + type: string + module: + $ref: '#/components/schemas/ModuleKey' + planDate: + type: string + format: date + recurrence: + $ref: '#/components/schemas/TaskRecurrence' + targetCount: + type: integer + minimum: 0 + note: + type: string + TaskUpdate: + type: object + minProperties: 1 + properties: + title: + type: string + status: + $ref: '#/components/schemas/TaskStatus' + targetCount: + type: integer + minimum: 0 + completedCount: + type: integer + minimum: 0 + note: + type: string + TaskResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/StudyTask' + TaskListResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/PageData-StudyTask' + + # ---------- Stats ---------- + ModuleStat: + type: object + required: [module, total, correct, correctRate, wrong] + properties: + module: + $ref: '#/components/schemas/ModuleKey' + total: + type: integer + correct: + type: integer + correctRate: + type: number + wrong: + type: integer + StatsOverview: + type: object + properties: + totalAnswered: + type: integer + totalCorrect: + type: integer + totalWrong: + type: integer + overallCorrectRate: + type: number + practiceStreak: + type: integer + moduleStats: + type: array + items: + $ref: '#/components/schemas/ModuleStat' + wrongByReason: + type: object + additionalProperties: + type: integer + StatsDailyPoint: + type: object + properties: + date: + type: string + format: date + answered: + type: integer + correctRate: + type: number + StatsModuleGroup: + type: object + properties: + module: + $ref: '#/components/schemas/ModuleKey' + total: + type: integer + correct: + type: integer + wrong: + type: integer + correctRate: + type: number + wrongByReason: + type: object + additionalProperties: + type: integer + StatsMonthly: + type: object + properties: + month: + type: string + format: yyyy-MM + totalAnswered: + type: integer + totalCorrect: + type: integer + totalWrong: + type: integer + overallCorrectRate: + type: number + moduleStats: + type: array + items: + $ref: '#/components/schemas/ModuleStat' + wrongByReason: + type: object + additionalProperties: + type: integer + streakSummary: + type: object + properties: + maxStreak: + type: integer + currentStreak: + type: integer + StatsOverviewResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/StatsOverview' + StatsDailyResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/StatsDailyPoint' + StatsModuleResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/StatsModuleGroup' + StatsMonthlyResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/StatsMonthly' + + # ---------- StatsSnapshot(统计聚合快照,持久化实体)---------- + StatsSnapshot: + type: object + required: [id, date, totalAnswered, totalCorrect, totalWrong, overallCorrectRate, moduleStats, wrongByReason, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + date: + type: string + format: date + totalAnswered: + type: integer + totalCorrect: + type: integer + totalWrong: + type: integer + overallCorrectRate: + type: number + practiceStreak: + type: integer + moduleStats: + type: array + items: + $ref: '#/components/schemas/ModuleStat' + wrongByReason: + type: object + additionalProperties: + type: integer + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + + # ---------- Settings ---------- + AppSettings: + type: object + required: [id, uiTheme, aiEnabled, createdAt, updatedAt] + properties: + id: + type: string + enum: ['app'] + targetScore: + type: number + dailyQuestionTarget: + type: integer + dailyStudyMinutes: + type: integer + uiTheme: + type: string + enum: [light, dark] + primaryColor: + type: string + aiEnabled: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + SettingsUpdate: + type: object + minProperties: 1 + properties: + targetScore: + type: number + dailyQuestionTarget: + type: integer + dailyStudyMinutes: + type: integer + uiTheme: + type: string + enum: [light, dark] + aiEnabled: + type: boolean + SettingsResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + $ref: '#/components/schemas/AppSettings' + + # ---------- Data 备份 ---------- + BackupPayload: + type: object + required: [schemaVersion, exportedAt] + properties: + schemaVersion: + type: integer + exportedAt: + type: string + format: date-time + questions: + type: array + items: + $ref: '#/components/schemas/Question' + answerRecords: + type: array + items: + $ref: '#/components/schemas/AnswerRecord' + wrongQuestions: + type: array + items: + $ref: '#/components/schemas/WrongQuestion' + mockExams: + type: array + items: + $ref: '#/components/schemas/MockExam' + shenlun: + type: array + items: + $ref: '#/components/schemas/ShenLunEssay' + tasks: + type: array + items: + $ref: '#/components/schemas/StudyTask' + stats: + type: array + items: + $ref: '#/components/schemas/StatsSnapshot' + settings: + $ref: '#/components/schemas/AppSettings' + BackupResult: + type: object + properties: + backupId: + type: string + createdAt: + type: string + format: date-time + filePath: + type: string + RestoreResult: + type: object + properties: + restoredAt: + type: string + format: date-time + entities: + type: array + items: + type: string + preBackupId: + type: string + description: 还原前自动生成的一次备份 id + + # ---------- 通用 ---------- + DeleteResponse: + allOf: + - $ref: '#/components/schemas/ApiSuccess' + - type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + id: + type: string + format: uuid + ImportResponse: + type: object + properties: + imported: + type: integer + skips: + type: array + items: + type: object + properties: + index: + type: integer + reason: + type: string + ApiSuccess: + type: object + required: [code, data] + properties: + code: + type: integer + enum: [0] + data: + description: 业务数据,由各响应的 data 属性具体定义 + message: + type: string + ApiFailure: + type: object + required: [code, data, message] + properties: + code: + type: integer + description: 非 0 错误码 + data: + type: object + nullable: true + message: + type: string + PageData-Question: + type: object + required: [items, total, page, limit, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/Question' + total: + type: integer + page: + type: integer + limit: + type: integer + hasMore: + type: boolean + PageData-WrongQuestion: + type: object + required: [items, total, page, limit, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/WrongQuestion' + total: + type: integer + page: + type: integer + limit: + type: integer + hasMore: + type: boolean + PageData-MockExam: + type: object + required: [items, total, page, limit, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/MockExam' + total: + type: integer + page: + type: integer + limit: + type: integer + hasMore: + type: boolean + PageData-ShenLunEssay: + type: object + required: [items, total, page, limit, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/ShenLunEssay' + total: + type: integer + page: + type: integer + limit: + type: integer + hasMore: + type: boolean + PageData-StudyTask: + type: object + required: [items, total, page, limit, hasMore] + properties: + items: + type: array + items: + $ref: '#/components/schemas/StudyTask' + total: + type: integer + page: + type: integer + limit: + type: integer + hasMore: + type: boolean + + responses: + BadRequest: + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiFailure' + ValidationError: + description: 校验失败(422 不可处理) + content: + application/json: + schema: + $ref: '#/components/schemas/ApiFailure' + NotFound: + description: 资源不存在 + content: + application/json: + schema: + $ref: '#/components/schemas/ApiFailure' diff --git a/deliverables/architecture/params.md b/deliverables/architecture/params.md new file mode 100644 index 0000000..4418d6a --- /dev/null +++ b/deliverables/architecture/params.md @@ -0,0 +1,161 @@ +# 备考中枢 · 技术约束清单与数据结构说明(params.md) + +> 版本:v2.0 · 2026-08-26 +> 用途:供前端 / 后端 / 测试开发阶段引用,与 `architecture.md` / `openapi.yaml` / `decisions/` 互为契约。 +> 组织方式:垂直切片(Handler 层 / Service 层 / 数据访问 + 领域类型层),单一契约源(zod + zod-openapi)。 + +--- + +## 一、技术栈与硬约束(必读铁律) + +| 项 | 约束 | +|---|---| +| 语言 | 全 TypeScript,**全程禁止 any**(评审铁律,违反即退回) | +| 后端 | Express 5.x + Node.js 24 LTS + ESM | +| 前端 | Vue 3.5 + Vite 6 + TS 5.8 + Vue Router 4 + Pinia | +| 数据层 | 本地 JSON 文件(`APP_DATA_DIR`,Docker volume 挂载) | +| 数据校验 | zod 运行时校验(读 JSON 必过,无 any) | +| **单一契约源** | 领域类型由 zod `z.infer` 产出;OpenAPI 契约由 `zod-openapi` 自动生成;请求校验用同一 zod schema —— 三者同源同步 | +| 版本锚定 | Node 24 LTS / Express 5.2 / Vue 3.5 / Vite 6 / TS 5.8 / zod 4.x / @asteasolutions/zod-to-openapi 8.x(安装后回写精确版) | +| 包管理 | pnpm 10+(monorepo workspace) | +| 包格式 | ESM(Express 5 原生支持 import) | +| 响应格式 | 统一 `ApiResponse`(code=0 成功,code!=0 失败) | +| API 版本 | 所有端点 `/api/v1/` 前缀 | +| 图标 | 前端图标**由设计/架构阶段锁定一套 SVG 图标库并全局统一,不混用**;API/架构文档不出现 emoji | +| 视觉 | 禁止紫色→粉色渐变方案 | +| 文案 | 禁止空洞占位文案 | +| 单文件 | 单文件 ≤ 300 行,单一职责,入口只装配零业务,依赖只向下 | + +--- + +## 二、数据目录(`APP_DATA_DIR`) + +环境变量 `APP_DATA_DIR`,默认 `./data`。Docker volume 挂载。 + +``` +${APP_DATA_DIR}/ +├── questions.json # 题库 +├── answer-records.json # 作答记录 +├── answer-records-YYYY-MM.json # 归档分片(阈值后按月切) +├── wrong-questions.json # 错题本 +├── mock-exams.json # 模考记录 +├── shenlun.json # 申论 +├── tasks.json # 备考计划 +├── stats.json # 统计快照 +├── settings.json # 偏好设置 +└── meta.json # schema 版本 + 最后写时间 +``` + +### 顶层索引结构(通用) +```jsonc +{ + "version": 1, + "updatedAt": "2026-08-26T12:00:00.000Z", + "index": { "": 0 }, + "items": [] +} +``` + +### 原子写入约定 +- 写 `.tmp` 后 `fs.rename` 原子替换(同文件系统 rename 原子)。 +- 单进程内所有写走一个 Promise 队列(互斥),逐个执行。 +- 高频写用防抖批写(< 500ms 合并),内存立即生效。 +- 读入文件必过 zod schema 校验(不可信输入收窄为强类型实体)。 + +--- + +## 三、领域实体清单(8 个) + +> 以下字段**均由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 定义**,类型经 `z.infer` 产出。表中字段为约定值(must-match),实际以 zod schema 为准。 + +| 实体 | 关键字段 | 说明 | +|---|---|---| +| Question | id, subject, module, type, stem, options, answer, analysis, source, difficulty, tags, createdAt/At | 题目 | +| AnswerRecord | id, questionId, module, correctness, isWrong, userAnswer, wrongReasons, tookMs, source, practiceDate | 作答记录(驱动统计) | +| WrongQuestion | id, questionId, module, wrongReasons, wrongCount, lastWrongAt, reviewCount, status, note | 错题本(错答自动入本) | +| MockExam | id, title, examType, fullScore, score, rank, durationMin, moduleScores, moduleCorrectRate, examDate | 模考 | +| ShenLunEssay | id, examId, topic, module, content, wordCount, durationMin, selfRating, aiFeedback | 申论 | +| StudyTask | id, title, module, planDate, status, recurrence, targetCount, completedCount, note | 备考任务 | +| StatsSnapshot | id, date, totalAnswered/Correct/Wrong, overallCorrectRate, practiceStreak, moduleStats, wrongByReason | 统计聚合快照 | +| AppSettings | id('app'), targetScore, dailyQuestionTarget, dailyStudyMinutes, uiTheme, primaryColor, aiEnabled | 偏好设置(单例) | + +### 枚举清单 +- 科目 `SubjectKey`:`xingce` | `shenlun` +- 模块 `ModuleKey`:`xingce-shuli` | `xingce-panduan` | `xingce-yanyu` | `xingce-changshi` | `xingce-ziliao` | `shenlun-zhuizong` | `shenlun-zonghe` | `shenlun-shenlun` +- 题型 `QuestionType`:`single` | `multiple` | `judge` | `blank` | `essay` +- 判定 `Correctness`:`correct` | `wrong` | `partial` | `blank` +- 错因 `WrongReason`:`knowledge-gap` | `concept-confusion` | `careless` | `time-pressure` | `method-unfamiliar` | `calculation-error` | `reading-error` +- 错题状态 `WrongQuestionStatus`:`open` | `resolved` | `mastered` +- 模考类型 `ExamType`:`national` | `province` | `self` +- 任务状态 `TaskStatus`:`todo` | `doing` | `done` | `skipped` +- 任务循环 `TaskRecurrence`:`once` | `daily` | `weekly` +- 主题 `uiTheme`:`light` | `dark`(默认 light,深蓝主色,不采用紫粉渐变) + +--- + +## 四、统一响应格式 + +```jsonc +// 成功 +{ "code": 0, "data": { ... } } +// 失败 +{ "code": 40001, "data": null, "message": "human readable" } +``` + +分页响应: +```jsonc +{ "code": 0, "data": { "items": [], "total": 100, "page": 1, "limit": 20, "hasMore": true } } +``` + +--- + +## 五、错误码约定 + +| 区段 | 含义 | +|---|---| +| 40001 | 请求参数错误(BadRequest) | +| 42200 | 校验失败(ValidationError,zod) | +| 40400 | 资源不存在(NotFound) | +| 50000 | 内部错误(Internal) | +| 50303 | 功能未启用(AI 二期 `FEATURE_DISABLED`) | +| 40900 | 数据写入冲突(写队列拒绝,罕见) | + +> 错误码为联合类型(`AppError`),catch 变量用 `unknown` 窄化,**禁止 any 强转**。 + +--- + +## 六、垂直切片分包要点(对照 `code-organization.md` §3) + +- **Handler 层(`routes/*.routes.ts`)**:路由注册 + zod 请求校验(`schema.parse`,用收窄后结果)+ 调 service + 组装 `ApiResponse`,端点变更只落在一个 route 文件。 +- **Service 层(`services/*.service.ts`)**:跨实体业务逻辑(抽题 / 判定 / 错题入本 / 增量聚合 / 备份还原),不 import req/res,不返回 HTTP 响应。 +- **数据访问 + 领域类型(`data/` + `packages/shared/src/schemas/`)**:`data/store.ts`(读 JSON + 原子写 + 写队列 + zod 校验)、`data/file-map.ts`;领域类型与请求校验均由 zod schema 单一契约源产出。 +- **前端**:页面在 `views/` 按产品页面分包(dashboard / question-entry / practice / mock-exam / task / news / settings);组合式函数在 `composables/`;API 调用统一封装 `services/api-client.ts`,解析 `ApiResponse`;类型由 openapi 契约生成(`orval` / `@hey-api/openapi-ts`)或复用 `packages/shared` 的 `z.infer` 类型。 +- 组件单文件 ≤ 300 行,单一职责。 + +--- + +## 七、单一契约源(zod + zod-openapi) + +- **领域类型**:`packages/shared/src/schemas/*.schema.ts` 的 zod schema → `z.infer` 产出具名类型,前后端共用,0 any。 +- **OpenAPI 契约**:同一批 zod schema → `zod-openapi`(`OpenAPIRegistry.register` / `registerPath` + `OpenApiGeneratorV3.generateDocument`)自动生成 `openapi.yaml`。 +- **请求校验**:route handler 用同一 schema `schema.parse(raw)`。 +- **改字段链路**:改 `packages/shared/src/schemas/x.schema.ts` 一处 → 重新生成 openapi 契约 → 前端重新生成 TS 类型 → route handler 校验自动跟随。类型、契约、校验三者永不失同步。 + +--- + +## 八、AI 扩展位(二期预留,本期不实现) + +- `apps/server/src/services/ai/providers/`:`AiProvider` 接口 + `NoopProvider` 空实现。 +- `Question.aiExplanation / aiConfidence`、`ShenLunEssay.aiFeedback` 字段已在 zod schema 预留。 +- `AppSettings.aiEnabled`(恒 false),`/api/v1/ai/*` 本期不挂路由,返回 50303。 + +--- + +## 九、Phase 2 端到端验证步骤 + +1. `pnpm install` → 核对版本与锚定表一致。 +2. `pnpm --filter build` 通过(strict + 无 any)。 +3. `pnpm --filter dev` → `curl /api/v1/settings` 返回 `{code:0,data:{...}}`。 +4. 核心闭环:`POST /questions` → `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 行。 diff --git a/deliverables/architecture/架构交付说明.md b/deliverables/architecture/架构交付说明.md new file mode 100644 index 0000000..02952e1 --- /dev/null +++ b/deliverables/architecture/架构交付说明.md @@ -0,0 +1,88 @@ +# 备考中枢 · 架构交付说明(垂直切片最终形态) + +> 汇编:项目总监 大湾区靓仔 · 2026-08-26 +> 产出:首席架构师 高见远 +> 版本:v2.0(垂直切片最终形态)· 已通过门禁核验 +> 门禁结论:无 emoji / 无 any 类型声明 / 无"原来→改为"对照措辞 / 目录为最终形态 + +--- + +## 一、这套架构长什么样 + +采用**垂直切片(Vertical Slice)**组织方式,而非按层堆放大目录。一个资源被"纵向切透"——从 HTTP 入口到数据落盘端到端归属一个切片,切片内按职责分 **3 层**,层与层之间依赖只向下。 + +### 3 层结构 + +| 层 | 职责 | 落地位置 | +|----|------|----------| +| **Handler 层** | 路由注册 + zod 请求校验 + 调 service + 组装 `ApiResponse` | `routes/*.routes.ts`(校验/编排/响应全在一个文件) | +| **Service 层** | 跨实体业务:抽题 / 判定 / 错题入本 / 增量聚合 / 备份还原 | `services/*.service.ts`(不碰 req/res) | +| **数据访问 + 领域类型层** | 读 JSON(zod 校验) + 原子写 + 写队列 + 领域类型 | `data/store.ts` + `packages/shared/schemas` | + +> 领域类型不手写 interface,由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出,前后端共用。 + +--- + +## 二、为什么改一个接口不再"跳来跳去" + +一个资源 = **一个 route 文件(内含 handler)+ 一个 service 文件**。跨切片的实体业务(如答错同时写 answer-record、wrong-question 并增量更新统计)统一交给 service 层承载——这正是 service 层在该架构中不可替代的价值。 + +**改一个典型接口的落点**: + +| 改动 | 落点 | 数量 | +|------|------|:---:| +| 改某个字段 / 新增字段 | 改 `shared/schemas/*.schema.ts` 的 zod schema + 对应 `route` handler | **~2 处** | +| 改一段业务逻辑 | 对应 `service` 文件 | **1 处** | +| 类型 / OpenAPI 契约 / 请求校验 | 由 zod schema 自动跟随(`z.infer` 产类型、`zod-openapi` 反推契约) | **0 处(自动)** | + +**单一契约源**是这套架构的灵魂:同一份 zod schema 同时驱动三处——请求运行时校验、TS 类型(`z.infer`)、OpenAPI 契约(`zod-openapi` 自动反推),三者永不失同步,杜绝手写契约导致的漂移。 + +--- + +## 三、关键工程化要点 + +| 项 | 说明 | +|----|------| +| **单一契约源** | zod schema 唯一来源 → `z.infer` 产类型 + `zod-openapi` 自动反推 OpenAPI 契约 + 同一 schema 做请求校验,三者同步 | +| **无 any** | tsconfig strict 全家桶(noImplicitAny / strictNullChecks / exactOptionalPropertyTypes / noUncheckedIndexedAccess / useUnknownInCatchVariables)+ `ApiResponse` 泛型 + catch 用 unknown 窄化 + zod 收窄 | +| **JSON 数据层三重防护** | zod 运行时校验(文件是不可信输入)+ 临时文件 rename 原子替换 + 写队列串行,保证数据资产永不损坏 | +| **数据资产** | `APP_DATA_DIR` 可配置 + Docker volume 挂载 + 导出 JSON/CSV / 备份 / 还原接口(还原前自动备份,坏备份不覆盖好数据) | +| **AI 扩展位** | 二期 P1 前置但本期零实现:`services/ai/` 占位 + `providers/` 接口 + `aiEnabled` 恒 false | + +--- + +## 四、8 个领域实体 + +`Question / AnswerRecord / WrongQuestion / MockExam / ShenLunEssay / StudyTask / StatsSnapshot / AppSettings`,全部由 `packages/shared/src/schemas/*.schema.ts` 的 zod schema 经 `z.infer` 产出具名类型。 + +--- + +## 五、版本锚定 + +| 技术 | 版本锚定 | +|------|----------| +| Node.js | 24.x(LTS) | +| Express | 5.2.x | +| Vue | 3.5.x / Vite 6.x | +| TypeScript | 5.8.x | +| zod | 4.x(若落 3.x,zod-to-openapi 须降级 v7.3.4) | +| @asteasolutions/zod-to-openapi | 8.x | + +> Phase 2 安装后需把 `package.json`/`pnpm-lock.yaml` 精确版本回写锚定表,使规格与实现同步。 + +--- + +## 六、交付物清单(全部在 `deliverables/architecture/`) + +| 文件 | 说明 | +|------|------| +| `architecture.md` | 核心架构文档(v2.0,垂直切片最终形态) | +| `openapi.yaml` | OpenAPI 契约(标注由 zod-openapi 自动反推生成,勿手改) | +| `params.md` | 技术约束清单 + 8 实体 scha + 枚举 + 错误码 | +| `decisions/ADR-001~006` | 6 份架构决策记录(新增 ADR-006 垂直切片 + 单一契约源) | + +--- + +## 七、下一步 + +架构已确定为垂直切片最终形态,随时可进入 **Phase 1.5 Spec 生成** 或直接进入 **Phase 2 设计细化 + 开发**。确认后我将推进。