引入openspec管理

This commit is contained in:
2025-12-22 18:29:23 +08:00
parent 2454e6d23a
commit b765a5d4ed
27 changed files with 1058 additions and 1814 deletions

View File

@@ -0,0 +1,41 @@
# Custom Focus Duration
## Context
- 当前考试页面 `src/pages/QuizPage.tsx` 仅实现倒计时与提交逻辑,未对“离开页面/切换标签页”进行任何约束或记录。
- 管理端已有系统配置能力(`system_configs` 表 + `SystemConfigModel` + 管理端配置页),适合用于落地“可配置的离开页面容忍时长”。
## Goals / Non-Goals
- Goals:
- 支持管理员配置“考试进行中允许离开页面的累计时长(秒)”,并在考试页面实时生效。
- 当累计离开页面时长超过阈值时,系统按统一规则处理(默认:自动交卷并提示原因)。
- 配置缺省时不改变现有考试流程(默认关闭该约束)。
- Non-Goals:
- 不实现更复杂的反作弊能力如摄像头、屏幕录制、进程检测、强制全屏、OS 级别限制)。
- 不新增外部依赖服务或第三方 SaaS。
## Decisions
- 决策:将“焦点容忍时长”作为系统配置项持久化,默认关闭。
- 方案:在 `system_configs` 中新增配置类型(例如 `quiz_focus_config`),形如 `{ enabled: boolean, maxUnfocusedSeconds: number }`
- 理由:与现有配置存储模式一致;默认关闭可避免对现有行为产生破坏性变更。
- 决策:焦点离开判定使用浏览器 Page Visibility 能力(`document.hidden`/`visibilitychange`)并累计离开时长。
- 理由:实现成本低、跨浏览器兼容性较好、无需引入新技术栈。
- 决策:超阈值处理默认“自动交卷”。
- 理由:规则清晰、可验证;与现有“时间到自动交卷”路径类似,便于复用提交逻辑。
## Risks / Trade-offs
- 误判风险:移动端切后台、系统弹窗等可能导致短暂 `hidden`
- 缓解:默认关闭;管理员可设置更宽松阈值(例如 1030 秒)。
- 可绕过:用户可在同一标签内切换窗口但仍保持可见,或使用分屏。
- 缓解:该能力只覆盖“标签页不可见”的场景;不将其描述为强反作弊。
- 体验影响:过严阈值会导致误触发交卷。
- 缓解:在 UI 明确提示该规则与当前阈值;触发前可选“超阈值预警”作为后续增强。
## Migration Plan
- 以“默认关闭”的配置发布(`enabled: false`),确保升级后不影响现有考试行为。
- 管理端提供配置入口后,由管理员在需要的考试场景手动开启并设置阈值。
- 回滚策略:关闭 `enabled` 即可恢复为原行为。
## Open Questions
- 是否需要将“违规触发记录”写入答题记录(`quiz_records`)以便统计与审计?
- 超阈值动作是否需要支持可配置(仅警告 / 记录违规 / 自动交卷)?

View File

@@ -0,0 +1,25 @@
## ADDED Requirements
### Requirement: Custom Focus Duration
系统 MUST 支持配置“考试进行中允许离开页面的累计时长(秒)”,并在考试页面生效。
#### Scenario: Default disabled
- **GIVEN** 焦点约束配置为关闭或不存在
- **WHEN** 用户在考试进行中切换到其他标签页导致页面不可见
- **THEN** 系统 MUST 不因焦点离开而自动交卷
#### Scenario: Enforce configured maximum
- **GIVEN** 焦点约束配置启用且 `maxUnfocusedSeconds` 为 10
- **WHEN** 用户在考试进行中累计离开页面 12 秒
- **THEN** 系统 MUST 自动提交该次考试
- **AND** 系统 MUST 告知用户自动提交的原因是“离开页面超时”
### Requirement: Unfocused Duration Accumulation
系统 MUST 在一次考试会话内累计多次离开页面的总时长,并以累计值与阈值比较。
#### Scenario: Multiple unfocused intervals
- **GIVEN** 焦点约束配置启用且 `maxUnfocusedSeconds` 为 10
- **WHEN** 用户先离开页面 6 秒后返回,再离开页面 5 秒后返回
- **THEN** 系统 MUST 将累计离开页面时长视为 11 秒
- **AND** 系统 MUST 自动提交该次考试

View File

@@ -0,0 +1,15 @@
## 1. 配置与接口
- [ ] 1.1 扩展系统配置模型以支持读取/更新焦点配置
- [ ] 1.2 增加管理端 API获取/更新焦点配置
- [ ] 1.3 增加前端管理页配置项:启用开关与最大离开秒数
## 2. 考试页面行为
- [ ] 2.1 在考试开始后监听 `visibilitychange` 并累计离开页面时长
- [ ] 2.2 超过阈值时自动交卷并展示原因提示
- [ ] 2.3 确保刷新/路由切换时事件监听被正确清理
## 3. 测试与校验
- [ ] 3.1 为新增管理端接口补充可执行的接口测试
- [ ] 3.2 为焦点累计逻辑补充前端单测或最小可验证测试
- [ ] 3.3 运行 `npm run check``npm run build`

View File

@@ -1,13 +0,0 @@
# Change: Fix QuizPage useEffect Bug
## Why
在QuizPage.tsx中useEffect钩子末尾错误地调用了clearQuiz()函数导致从SubjectSelectionPage传递过来的题目数据被立即清除引发"Cannot read properties of undefined (reading 'type')"错误。
## What Changes
- 移除QuizPage.tsx useEffect钩子末尾的clearQuiz()调用
- 修改清除逻辑只清除answers对象而保留题目数据
- 添加对currentQuestion的空值检查确保组件正确渲染
## Impact
- Affected specs: quiz
- Affected code: src/pages/QuizPage.tsx

View File

@@ -1,17 +0,0 @@
## MODIFIED Requirements
### Requirement: Quiz Page State Management
The system SHALL preserve question data when navigating to QuizPage from other pages, and only clear答题状态(answers) to ensure proper component rendering.
#### Scenario: Navigation from SubjectSelectionPage
- **WHEN** user selects a subject and navigates to QuizPage
- **THEN** the system SHALL preserve the questions data
- **THEN** the system SHALL clear only the answers state
- **THEN** the system SHALL render the first question correctly
### Requirement: Quiz Page Error Handling
The system SHALL properly handle null or undefined question data to prevent runtime errors during rendering.
#### Scenario: Null Question Data
- **WHEN** currentQuestion is null or undefined
- **THEN** the system SHALL display a loading state instead of crashing
- **THEN** the system SHALL NOT attempt to access properties of undefined objects

View File

@@ -1,8 +0,0 @@
## 1. Implementation
- [x] 1.1 移除QuizPage.tsx useEffect钩子末尾的clearQuiz()调用
- [x] 1.2 修改清除逻辑只清除answers对象而保留题目数据
- [x] 1.3 添加对currentQuestion的空值检查确保组件正确渲染
## 2. Validation
- [x] 2.1 运行项目确保bug已修复
- [x] 2.2 验证从SubjectSelectionPage可以正常跳转到QuizPage并显示题目

View File

@@ -1,38 +0,0 @@
# Change: User Group Management System
## Status
**Pending Testing** (Note: Add User Group functionality is not yet tested)
## Why
To manage users more efficiently by grouping them and assigning exam tasks to groups.
## What Changes
1. **User Group Management**:
* Add "User Group" management module in User Management interface.
* Support CRUD for user groups.
* System built-in "All Users" group.
2. **User-Group Association**:
* Show user groups in user details.
* Support multi-select for user groups.
* Audit log for group changes.
3. **Exam Task Assignment**:
* Support assigning tasks by individual users and user groups.
* Handle duplicate selections (user in selected group).
* Show unique user count.
4. **Permissions**:
* Admin only for managing groups.
5. **Data Consistency**:
* Cascade delete for groups and users.
* Protect "All Users" group.
## Impact
- Affected specs: user-group
- Affected code:
- api/database/index.ts
- api/models/userGroup.ts
- api/controllers/userGroupController.ts
- api/controllers/userController.ts
- api/controllers/examTaskController.ts
- src/pages/admin/UserManagePage.tsx
- src/pages/admin/UserGroupManage.tsx
- src/pages/admin/ExamTaskPage.tsx

View File

@@ -1,44 +0,0 @@
# Spec: User Group Management
## 1. User Group Management Functionality
- **Module**: New "User Group" management module in User Management interface.
- **CRUD**: Support Create, Read, Update, Delete for user groups.
- **Group Info**: Group Name, Description, Created Time.
- **"All Users" Special Group**:
- All users automatically belong to this group.
- Users cannot voluntarily exit this group.
- New users automatically join this group.
- This group cannot be deleted.
## 2. User-Group Association Management
- **Display**: User details page shows the list of user groups.
- **Assignment**: Support multi-select to set user groups.
- **Multi-group**: Single user can belong to multiple groups.
- **Audit**: Log user group changes (Audit log).
## 3. Exam Task Assignment Functionality
- **Assignment Methods**:
- Select by Individual User.
- Select by User Group.
- **Hybrid Selection**: Support selecting both specific users and user groups simultaneously.
- **Deduplication**:
- System automatically removes duplicates when a user is selected directly and also belongs to a selected group.
- Keep only one instance in the task assignment result.
- Record original selection in assignment log.
## 4. Permissions & Verification
- **Admin**: Adding/Modifying user groups requires administrator permissions.
- **Scope**: Users can only view groups they have permission to manage.
- **Verification**: Both Frontend and Backend must verify user group operation permissions.
## 5. Data Consistency Assurance
- **Delete Group**: Automatically remove all user associations when a group is deleted.
- **Delete User**: Automatically remove from all user groups when a user is deleted.
- **Protection**: Built-in "All Users" group cannot be modified or deleted.
## 6. Interface Design Requirements
- **Component**: Use multi-select component for user group selection.
- **Identification**: Clearly identify the "All Users" special group.
- **Count Display**: Clearly display the final actual assigned user count (after deduplication) in the Exam Task Assignment interface.
**Note**: The "Add User Group" functionality has been implemented but is currently **untested**.

View File

@@ -1,31 +1,74 @@
# Project Context
## Purpose
[Describe your project's purpose and goals]
本项目是一个面向“考试/答题/问卷”场景的 Web 应用,支持用户免注册答题、题库管理、考试科目与任务分派、答题记录与统计分析,并提供管理员端的日常运营能力(题库/科目/任务/用户管理、导入导出、配置管理等)。
## Tech Stack
- [List your primary technologies]
- [e.g., TypeScript, React, Node.js]
- 前端React 18 + TypeScript + Vite + React Router + Ant Design + Tailwind CSS + Zustand
- 后端Node.js + Express + TypeScript`tsx` 运行,`nodemon` 热重载)
- 数据库SQLite3本地文件数据库
- 文件处理Multer上传+ XLSXExcel 导入/导出)
- 其他AxiosHTTP、UUID、dotenv、concurrently
## Project Conventions
### Code Style
[Describe your code style preferences, formatting rules, and naming conventions]
- 语言与模块:项目为 ESM`package.json:type=module`),前后端均以 TypeScript 为主。
- 命名风格:前端组件/页面使用 PascalCase变量与函数使用 camelCase路由资源使用 kebab 或小写复数(以现有接口为准)。
- 错误返回:后端接口大多返回 `{ success: boolean, message?: string, data?: any, pagination?: any }` 的统一结构(见 `api/middlewares/index.ts``responseFormatter` 相关逻辑与各控制器实现)。
- 代码组织:尽量沿用现有分层与文件结构,不引入新的技术栈或重复实现。
### Architecture Patterns
[Document your architectural decisions and patterns]
- 前端:`src/pages` 放页面级组件;`src/components` 放复用组件;`src/contexts` 存放上下文状态;`src/services/api.ts` 封装接口访问;`src/utils` 存放通用工具。
- 后端:以 `api/server.ts` 为主要 API 入口,使用 Express Router 组织路由;业务按 `controllers/`HTTP 处理)与 `models/`(数据库访问与领域逻辑)分层。
- 数据库初始化:`api/database/init.sql` 负责建表/初始化;应用启动时由 `initDatabase()` 执行初始化与连接。
- 环境变量:使用 `dotenv` 加载 `.env`;端口默认 `3001`(可通过 `PORT` 覆盖SQLite 文件路径可通过 `DB_PATH` 覆盖。
- 服务端口:
- 前端开发:`http://localhost:5173/`
- 后端开发:`http://localhost:3001/api`
- 本地代理Vite 将 `/api` 代理到 `http://localhost:3001`(见 `vite.config.ts`),前端请求统一走相对路径 `/api`(见 `src/utils/request.ts`)。
### Runbook
- 安装依赖:`npm install`
- 启动开发:`npm run dev`(并行启动 `dev:api``dev:frontend`
- 类型检查:`npm run check`
- 生产构建:`npm run build`
- 启动生产:`npm start`(运行 `dist/api/server.js` 并托管 `dist/` 下静态文件)
- 目录约定:前后端构建产物当前共享 `dist/` 目录;若构建过程出现互相覆盖,需要调整构建输出目录或构建顺序。
### OpenSpec 工作流(本仓库约定)
- 查看现有规格:`openspec list --specs`
- 查看变更提案:`openspec list`
- 校验变更提案:`openspec validate <change-id> --strict`
- 交互式选择:`openspec show``openspec validate`
### Testing Strategy
[Explain your testing approach and requirements]
- 优先保证:`npm run check`TypeScript 类型检查)与 `npm run build`(生产构建)可通过。
- 目前仓库存在 `test/` 下的接口测试样例Jest + Supertest 风格),但 `package.json` 未提供统一的 `test` 脚本与相关依赖声明时需先补齐再启用。
- 功能新增或行为变更应补充可执行的自动化测试(单测/接口测试均可),并避免引入仅用于一次性验证的脚本逻辑进入生产代码路径。
### Git Workflow
[Describe your branching strategy and commit conventions]
- 建议以功能为单位创建分支,保持提交粒度清晰(一个提交聚焦一个主题)。
- 与规范相关的变更(需求/行为/接口/数据结构)优先通过 OpenSpec 变更提案(`openspec/changes/<change-id>/`)描述,再进入实现。
## Domain Context
[Add domain-specific knowledge that AI assistants need to understand]
- 核心领域对象:
- 用户User通过姓名 + 手机号进入系统;密码字段目前存在且为敏感信息。
- 题库Question / QuestionCategory支持题目类别、题型单选/多选/判断/文本)、导入导出。
- 考试科目ExamSubject描述抽题规则题型比例、类别比例、总分、时长
- 考试任务ExamTask面向一组用户分派某科目在开始/结束时间范围内有效;支持按用户与按用户组混合选择,并在服务端进行去重;原始选择通过 `selectionConfig`JSON 字符串)保留。
- 答题QuizRecord / QuizAnswer记录得分、正确数、明细答案等。
- 系统配置SystemConfig保存抽题配置、管理员账号配置等。
- 用户组UserGroup / UserGroupMember
- 内置“全体用户”系统组:新用户自动加入;不可删除/不可修改;用户不可主动退出。
- 注意:用户组相关功能(管理、成员关系、考试任务按组分派与混合选择)目前已实现但处于未测试状态,且缺少“用户组变更审计日志”能力。
- 管理员能力:管理员登录目前为简化实现(固定账号与 token并通过 `adminAuth` 中间件保护管理接口。
## Important Constraints
[List any technical, business, or regulatory constraints]
- 鉴权现状:`adminAuth` 当前为简化放行逻辑,`/api/admin/login` 返回固定 token生产环境需要替换为真实鉴权例如 JWT 校验)并在前后端一致落地。
- 安全:用户密码目前存在明文存储/传输路径的风险,属于待整改项;任何日志与导出都应避免泄露敏感信息。
- 数据库:使用 SQLite 文件库,适合轻量/单机;并发与事务能力有限,涉及批量写入或一致性要求时需谨慎设计。
## External Dependencies
[Document key external services, APIs, or systems]
- 当前不依赖外部第三方服务;主要外部依赖为本地 SQLite 数据文件(`data/` 目录)与 Excel 文件导入导出能力。
- 若未来接入统一登录、对象存储、日志审计等外部服务,应在 OpenSpec 规范中补充依赖与接口约束。

View File

@@ -0,0 +1,127 @@
version: 1
id: api_response_schema
title: Unified API Response Envelope
sources:
project_md:
- path: openspec/project.md
lines: "16-19"
middleware:
- path: api/middlewares/index.ts
lines: "76-94"
legacy_app:
- path: api/app.ts
lines: "45-66"
json_schema_draft: "2020-12"
schemas:
Pagination:
type: object
required:
- page
- limit
- total
- pages
properties:
page:
type: integer
minimum: 1
limit:
type: integer
minimum: 1
total:
type: integer
minimum: 0
pages:
type: integer
minimum: 0
additionalProperties: true
ApiResponse:
type: object
required:
- success
properties:
success:
type: boolean
message:
type: string
data:
description: |
业务返回体,形态取决于具体接口:
- 单对象 / 列表 / 聚合对象 / 统计对象
nullable: true
oneOf:
- type: object
- type: array
- type: string
- type: number
- type: boolean
pagination:
$ref: "#/schemas/Pagination"
errors:
description: |
业务校验错误明细(当前仅在部分接口中使用,如用户数据校验)。
type: array
items:
type: string
error:
description: |
兼容字段:`api/app.ts` 的错误/404 处理使用 `error` 字段。
type: string
additionalProperties: true
constraints:
- id: envelope_is_best_effort
status: implemented
evidence:
- api/middlewares/index.ts:76
details: |
响应格式化中间件仅在返回体不含 `success` 字段时进行包装:
- 包装为 `{ success: true, data: <original> }`
- 控制器自行返回 `{ success: boolean, ... }` 时不二次包装
- id: legacy_error_shape_exists
status: implemented
evidence:
- api/app.ts:45
- api/app.ts:58
details: |
`api/app.ts` 的错误与 404 返回使用 `{ success: false, error: string }`
与 project.md 中的 `{ success, message, data }` 不完全一致。
examples:
success_with_data:
success: true
data:
id: "uuid"
success_with_message_and_data:
success: true
message: "登录成功"
data:
token: "admin-token"
success_with_pagination:
success: true
data: []
pagination:
page: 1
limit: 10
total: 0
pages: 0
error_with_message:
success: false
message: "参数不完整"
error_with_errors_array:
success: false
message: "数据验证失败"
errors:
- "手机号格式不正确"
legacy_error:
success: false
error: "API not found"

View File

@@ -0,0 +1,103 @@
version: 1
id: auth_rules
title: Authorization Rules
sources:
primary_server:
path: api/server.ts
middleware:
path: api/middlewares/index.ts
project_md:
path: openspec/project.md
lines: "65-69"
legacy_app:
path: api/app.ts
auth_model:
current_state: simplified_admin_auth
description: |
管理端接口普遍加了 `adminAuth` 中间件,但 `adminAuth` 当前为放行实现,
不校验 token、不区分用户身份。
roles:
- id: public
description: "无需鉴权的访问方(当前系统中也包含前台用户行为)。"
- id: admin
description: "管理端访问方(当前仅通过前端保存 token 来区分 UI 状态)。"
mechanisms:
admin_login:
endpoint: "POST /api/admin/login"
returns:
token:
type: string
fixed_value: "admin-token"
enforcement:
middleware: adminAuth
effective: allow_all
evidence:
- api/controllers/adminController.ts:1
- api/middlewares/index.ts:57
middlewares:
adminAuth:
file: api/middlewares/index.ts
behavior: allow_all
notes: "当前实现为 next() 直接放行;生产环境需替换为真实鉴权。"
route_policies:
- id: admin_namespace
match:
path_prefix: /api/admin/
intended_guard: adminAuth
effective_guard: none
notes: "server.ts 中几乎所有 /api/admin/* 路由均挂载 adminAuth。"
- id: admin_login
match:
path: /api/admin/login
methods: [POST]
intended_guard: none
effective_guard: none
- id: admin_protected_non_admin_prefix
match:
routes:
- path: /api/questions
methods: [POST]
- path: /api/questions/:id
methods: [PUT, DELETE]
- path: /api/questions/import
methods: [POST]
- path: /api/questions/export
methods: [GET]
- path: /questions/export
methods: [GET]
- path: /api/quiz/records
methods: [GET]
intended_guard: adminAuth
effective_guard: none
legacy_routes:
- id: auth_demo
mount_path: /api/auth
file: api/routes/auth.ts
status: not_implemented
notes: "register/login/logout 均为 TODO占位路由。"
constraints:
- id: admin_auth_is_not_enforced
severity: high
evidence:
- api/middlewares/index.ts:57
- openspec/project.md:68
details: |
`adminAuth` 当前不校验任何凭证,导致管理接口实际可被任何请求方访问。
- id: admin_token_is_fixed
severity: medium
evidence:
- api/controllers/adminController.ts:1
- openspec/project.md:68
details: |
`/api/admin/login` 返回固定 token `admin-token`,不具备会话隔离与过期能力。

View File

@@ -0,0 +1,425 @@
version: 1
id: database_schema
title: SQLite Database Schema
sources:
init_sql:
path: api/database/init.sql
init_code:
path: api/database/index.ts
notes: "仅在 users 表不存在时执行 init.sql"
models_dir:
path: api/models
database:
engine: sqlite3
file_path:
env: DB_PATH
default: data/survey.db
pragmas:
foreign_keys: true
evidence:
- api/database/index.ts:36
tables:
users:
source:
init_sql_lines: "1-12"
model: api/models/user.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
name:
type: TEXT
nullable: false
checks:
- "length(name) >= 2 AND length(name) <= 20"
phone:
type: TEXT
nullable: false
unique: true
checks:
- "length(phone) = 11"
- "phone LIKE '1%'"
- "substr(phone, 2, 1) BETWEEN '3' AND '9'"
password:
type: TEXT
nullable: false
default: "''"
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
indexes:
- name: idx_users_phone
columns: [phone]
- name: idx_users_created_at
columns: [created_at]
questions:
source:
init_sql_lines: "14-29"
model: api/models/question.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
content:
type: TEXT
nullable: false
type:
type: TEXT
nullable: false
checks:
- "type IN ('single', 'multiple', 'judgment', 'text')"
options:
type: TEXT
nullable: true
notes: "JSON 字符串,存储选项数组"
answer:
type: TEXT
nullable: false
notes: "多选题答案可能为 JSON 字符串或可解析为数组的字符串"
score:
type: INTEGER
nullable: false
checks:
- "score > 0"
category:
type: TEXT
nullable: false
default: "'通用'"
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
indexes:
- name: idx_questions_type
columns: [type]
- name: idx_questions_score
columns: [score]
- name: idx_questions_category
columns: [category]
question_categories:
source:
init_sql_lines: "31-38"
model: api/models/questionCategory.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
name:
type: TEXT
nullable: false
unique: true
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
seed:
- id: default
name: 通用
exam_subjects:
source:
init_sql_lines: "40-50"
model: api/models/examSubject.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
name:
type: TEXT
nullable: false
unique: true
type_ratios:
type: TEXT
nullable: false
notes: "JSON 字符串"
category_ratios:
type: TEXT
nullable: false
notes: "JSON 字符串"
total_score:
type: INTEGER
nullable: false
duration_minutes:
type: INTEGER
nullable: false
default: 60
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
updated_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
exam_tasks:
source:
init_sql_lines: "52-63"
model: api/models/examTask.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
name:
type: TEXT
nullable: false
subject_id:
type: TEXT
nullable: false
foreign_key:
table: exam_subjects
column: id
start_at:
type: DATETIME
nullable: false
end_at:
type: DATETIME
nullable: false
selection_config:
type: TEXT
nullable: true
notes: "JSON 字符串;模型在读写该列,但 init.sql 未包含该列"
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
indexes:
- name: idx_exam_tasks_subject_id
columns: [subject_id]
exam_task_users:
source:
init_sql_lines: "65-77"
model: api/models/examTask.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
task_id:
type: TEXT
nullable: false
foreign_key:
table: exam_tasks
column: id
on_delete: CASCADE
user_id:
type: TEXT
nullable: false
foreign_key:
table: users
column: id
on_delete: CASCADE
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
uniques:
- columns: [task_id, user_id]
indexes:
- name: idx_exam_task_users_task_id
columns: [task_id]
- name: idx_exam_task_users_user_id
columns: [user_id]
quiz_records:
source:
init_sql_lines: "79-98"
model: api/models/quiz.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
user_id:
type: TEXT
nullable: false
foreign_key:
table: users
column: id
subject_id:
type: TEXT
nullable: true
foreign_key:
table: exam_subjects
column: id
task_id:
type: TEXT
nullable: true
foreign_key:
table: exam_tasks
column: id
total_score:
type: INTEGER
nullable: false
correct_count:
type: INTEGER
nullable: false
total_count:
type: INTEGER
nullable: false
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
indexes:
- name: idx_quiz_records_user_id
columns: [user_id]
- name: idx_quiz_records_created_at
columns: [created_at]
- name: idx_quiz_records_subject_id
columns: [subject_id]
- name: idx_quiz_records_task_id
columns: [task_id]
quiz_answers:
source:
init_sql_lines: "100-115"
model: api/models/quiz.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
record_id:
type: TEXT
nullable: false
foreign_key:
table: quiz_records
column: id
question_id:
type: TEXT
nullable: false
foreign_key:
table: questions
column: id
user_answer:
type: TEXT
nullable: false
notes: "字符串或 JSON 字符串"
score:
type: INTEGER
nullable: false
is_correct:
type: BOOLEAN
nullable: false
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
indexes:
- name: idx_quiz_answers_record_id
columns: [record_id]
- name: idx_quiz_answers_question_id
columns: [question_id]
system_configs:
source:
init_sql_lines: "117-131"
model: api/models/systemConfig.ts
columns:
id:
type: TEXT
primary_key: true
nullable: false
config_type:
type: TEXT
nullable: false
unique: true
config_value:
type: TEXT
nullable: false
notes: "JSON 字符串或普通字符串"
updated_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
seed:
- id: "1"
config_type: quiz_config
config_value: "{\"singleRatio\":40,\"multipleRatio\":30,\"judgmentRatio\":20,\"textRatio\":10,\"totalScore\":100}"
- id: "2"
config_type: admin_user
config_value: "{\"username\":\"admin\",\"password\":\"admin123\"}"
user_groups:
source:
inferred_from_models:
- api/models/userGroup.ts
notes: "模型读写该表,但 init.sql 未包含该表"
columns:
id:
type: TEXT
primary_key: true
nullable: false
name:
type: TEXT
nullable: false
unique: true
description:
type: TEXT
nullable: false
default: "''"
is_system:
type: INTEGER
nullable: false
default: 0
notes: "0/1 标记系统内置用户组"
created_at:
type: DATETIME
nullable: false
default: CURRENT_TIMESTAMP
user_group_members:
source:
inferred_from_models:
- api/models/userGroup.ts
notes: "模型读写该表,但 init.sql 未包含该表"
columns:
group_id:
type: TEXT
nullable: false
foreign_key:
table: user_groups
column: id
user_id:
type: TEXT
nullable: false
foreign_key:
table: users
column: id
created_at:
type: DATETIME
nullable: true
notes: "模型查询中使用 m.created_at 排序,推断该列存在"
uniques:
- columns: [group_id, user_id]
constraints:
- id: init_sql_missing_user_group_tables
status: implemented_in_models_not_in_init_sql
evidence:
- api/models/userGroup.ts:1
- api/database/init.sql:1
details: |
`init.sql` 未创建 `user_groups` / `user_group_members`,但后端模型与路由已使用该表。
新初始化数据库时可能导致相关接口运行失败或功能不可用。
- id: init_sql_missing_exam_tasks_selection_config
status: implemented_in_models_not_in_init_sql
evidence:
- api/models/examTask.ts:201
- api/database/init.sql:52
details: |
`exam_tasks.selection_config` 在模型中读写,但 init.sql 的 exam_tasks 建表语句未包含该列。

102
openspec/specs/nfr.yaml Normal file
View File

@@ -0,0 +1,102 @@
version: 1
id: nfr
title: Non-Functional Requirements
sources:
project_md:
path: openspec/project.md
lines: "67-70"
middleware:
- api/middlewares/index.ts
database:
- api/database/index.ts
security:
password_handling:
status: not_compliant
current_state:
storage: "明文存储在 users.passwordSQLite"
transmission: "前后端请求体中直接传输 password 字段"
evidence:
- api/models/user.ts:18
- api/controllers/userController.ts:81
- openspec/project.md:69
required_state:
storage: "使用强哈希算法存储(例如 bcrypt/scrypt/argon2不存明文"
transmission: "避免回传密码;日志与导出不得包含敏感字段"
constraints:
- "当前实现未满足 required_state属于待整改项"
admin_authentication:
status: not_compliant
current_state:
admin_login_token: "固定值 admin-token"
route_guard: "adminAuth 中间件放行"
evidence:
- api/controllers/adminController.ts:1
- api/middlewares/index.ts:57
- openspec/project.md:68
required_state:
token_validation: "生产环境需实现真实鉴权(例如 JWT 校验)并在前后端一致落地"
constraints:
- "当前管理接口在后端层面不具备访问控制"
logging_sensitivity:
status: partial
current_state:
request_logging: "记录 method/path/statusCode/duration"
evidence:
- api/middlewares/index.ts:65
constraints:
- "应避免在日志中输出密码、token、导出数据等敏感信息当前需持续自查"
reliability:
database_initialization:
status: implemented
behavior: "仅当 users 表不存在时执行 init.sql"
evidence:
- api/database/index.ts:109
constraints:
- "若数据库存在但缺少部分表/列例如用户组、selection_config当前不会自动迁移"
performance:
limits:
request_body_max_bytes:
status: implemented
value: 10485760
evidence:
- api/server.ts:30
upload_max_bytes:
status: implemented
value: 10485760
evidence:
- api/middlewares/index.ts:7
database_characteristics:
status: implemented
notes: "SQLite 适合单机/轻量;并发与事务能力有限。"
evidence:
- openspec/project.md:70
compliance:
data_minimization:
status: partial
stored_personal_data:
- field: users.name
- field: users.phone
constraints:
- "当前未见用户数据保留期限/删除流程的实现"
gdpr_like_rights:
status: not_implemented
requirements:
- "数据导出:提供用户个人数据导出能力(当前仅管理员数据导出,且范围为业务数据)"
- "数据删除:支持按合规要求删除用户数据并处理关联记录"
constraints:
- "以上为合规目标要求;当前代码中未实现对应流程"
operability:
configuration:
status: implemented
mechanism: "dotenv + system_configs 表"
evidence:
- openspec/project.md:25
- api/models/systemConfig.ts:1

View File

@@ -0,0 +1,97 @@
version: 1
id: tech_stack
title: Technology Stack Baseline
sources:
project_md:
path: openspec/project.md
lines: "6-38"
package_json:
path: package.json
runtime:
language: TypeScript
module_system: ESM
evidence:
- package.json:5
frontend:
framework:
name: react
version: "18.x"
router:
name: react-router-dom
version: "6.x"
build_tool:
name: vite
version: "4.x"
ui:
- name: antd
version: "5.x"
- name: tailwindcss
version: "3.x"
- name: tailwind-merge
version: "3.x"
state:
- name: zustand
version: "4.x"
http_client:
- name: axios
version: "1.x"
backend:
platform: nodejs
framework:
name: express
version: "4.x"
dev_runtime:
- name: tsx
purpose: "运行 TypeScript 后端入口"
- name: nodemon
purpose: "热重载"
middlewares:
- name: cors
- name: multer
purpose: "文件上传"
data:
database:
engine: sqlite3
mode: "file-based"
utilities:
- name: dotenv
- name: uuid
file_processing:
excel:
- name: xlsx
purpose: "Excel 导入/导出"
dev_workflow:
scripts:
dev: "concurrently \"npm run dev:api\" \"npm run dev:frontend\""
dev_api: "nodemon --exec tsx api/server.ts"
dev_frontend: "vite"
check: "tsc --noEmit"
build: "tsc && vite build"
start: "node dist/api/server.js"
ports:
frontend_dev_default: 5173
backend_dev_default: 3001
proxy:
frontend_to_backend:
path_prefix: /api
target: "http://localhost:3001"
evidence:
- openspec/project.md:29
build_output:
directory: dist
notes: "前后端构建产物共享 dist 目录(见 project.md 约定)。"
environment_variables:
PORT:
description: "后端监听端口"
default: 3001
DB_PATH:
description: "SQLite 数据库文件路径"
default: "data/survey.db"