引入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,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"