引入openspec管理
This commit is contained in:
41
openspec/changes/add-custom-focus-duration/proposal.md
Normal file
41
openspec/changes/add-custom-focus-duration/proposal.md
Normal 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`。
|
||||
- 缓解:默认关闭;管理员可设置更宽松阈值(例如 10–30 秒)。
|
||||
- 可绕过:用户可在同一标签内切换窗口但仍保持可见,或使用分屏。
|
||||
- 缓解:该能力只覆盖“标签页不可见”的场景;不将其描述为强反作弊。
|
||||
- 体验影响:过严阈值会导致误触发交卷。
|
||||
- 缓解:在 UI 明确提示该规则与当前阈值;触发前可选“超阈值预警”作为后续增强。
|
||||
|
||||
## Migration Plan
|
||||
- 以“默认关闭”的配置发布(`enabled: false`),确保升级后不影响现有考试行为。
|
||||
- 管理端提供配置入口后,由管理员在需要的考试场景手动开启并设置阈值。
|
||||
- 回滚策略:关闭 `enabled` 即可恢复为原行为。
|
||||
|
||||
## Open Questions
|
||||
- 是否需要将“违规触发记录”写入答题记录(`quiz_records`)以便统计与审计?
|
||||
- 超阈值动作是否需要支持可配置(仅警告 / 记录违规 / 自动交卷)?
|
||||
|
||||
@@ -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 自动提交该次考试
|
||||
|
||||
15
openspec/changes/add-custom-focus-duration/tasks.md
Normal file
15
openspec/changes/add-custom-focus-duration/tasks.md
Normal 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`
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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并显示题目
|
||||
@@ -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
|
||||
@@ -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**.
|
||||
@@ -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(上传)+ XLSX(Excel 导入/导出)
|
||||
- 其他:Axios(HTTP)、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 规范中补充依赖与接口约束。
|
||||
|
||||
127
openspec/specs/api_response_schema.yaml
Normal file
127
openspec/specs/api_response_schema.yaml
Normal 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"
|
||||
|
||||
103
openspec/specs/auth_rules.yaml
Normal file
103
openspec/specs/auth_rules.yaml
Normal 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`,不具备会话隔离与过期能力。
|
||||
|
||||
425
openspec/specs/database_schema.yaml
Normal file
425
openspec/specs/database_schema.yaml
Normal 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
102
openspec/specs/nfr.yaml
Normal 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.password(SQLite)"
|
||||
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
|
||||
|
||||
97
openspec/specs/tech_stack.yaml
Normal file
97
openspec/specs/tech_stack.yaml
Normal 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"
|
||||
|
||||
Reference in New Issue
Block a user