引入openspec管理
This commit is contained in:
@@ -1,233 +0,0 @@
|
||||
domains:
|
||||
- name: "User"
|
||||
description: "参与答题的用户"
|
||||
entities:
|
||||
- name: "User"
|
||||
description: "用户信息实体"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "用户唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "用户姓名,2-20位中英文"
|
||||
- name: "phone"
|
||||
type: "string"
|
||||
description: "手机号,11位数字"
|
||||
- name: "password"
|
||||
type: "string"
|
||||
description: "登录密码 (敏感字段,仅管理员可见;当前版本为明文存储)"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
- name: "UserGroup"
|
||||
description: "用户组 (Note: The User Group feature (including management, assignment, and mixed selection) is implemented but currently UNTESTED. Missing Audit Log feature for User Group changes.)"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "用户组唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "用户组名称"
|
||||
- name: "description"
|
||||
type: "string"
|
||||
description: "用户组描述"
|
||||
- name: "isSystem"
|
||||
type: "boolean"
|
||||
description: "是否为系统内置组 (如: 全体用户)"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
rules:
|
||||
- "All Users (全体用户) special group: Auto-join for new users; Cannot be deleted or modified; Users cannot exit."
|
||||
|
||||
- name: "Question"
|
||||
description: "题库管理"
|
||||
entities:
|
||||
- name: "QuestionCategory"
|
||||
description: "题目类别"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "类别唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "类别名称 (例如:通用/网络/数据库等)"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
- name: "Question"
|
||||
description: "题目实体"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "题目唯一标识"
|
||||
- name: "content"
|
||||
type: "string"
|
||||
description: "题目内容"
|
||||
- name: "type"
|
||||
type: "enum"
|
||||
values: ["single", "multiple", "judgment", "text"]
|
||||
description: "题型"
|
||||
- name: "category"
|
||||
type: "string"
|
||||
description: "题目类别名称 (默认:通用)"
|
||||
- name: "options"
|
||||
type: "json"
|
||||
description: "选项内容 (JSON格式)"
|
||||
- name: "answer"
|
||||
type: "string"
|
||||
description: "标准答案"
|
||||
- name: "score"
|
||||
type: "integer"
|
||||
description: "分值"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
|
||||
- name: "Exam"
|
||||
description: "考试科目与考试任务"
|
||||
entities:
|
||||
- name: "ExamSubject"
|
||||
description: "考试科目 (一套出题规则)"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "科目唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "科目名称"
|
||||
- name: "totalScore"
|
||||
type: "integer"
|
||||
description: "总分"
|
||||
- name: "timeLimitMinutes"
|
||||
type: "integer"
|
||||
description: "答题时间限制(分钟),默认60"
|
||||
- name: "typeRatios"
|
||||
type: "json"
|
||||
description: "题型比重 (single/multiple/judgment/text)"
|
||||
- name: "categoryRatios"
|
||||
type: "json"
|
||||
description: "题目类别比重 (categoryName -> ratio)"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
- name: "updatedAt"
|
||||
type: "datetime"
|
||||
description: "更新时间"
|
||||
- name: "ExamTask"
|
||||
description: "考试任务 (给用户分派某个科目)"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "任务唯一标识"
|
||||
- name: "name"
|
||||
type: "string"
|
||||
description: "任务名称"
|
||||
- name: "subjectId"
|
||||
type: "string"
|
||||
description: "关联科目ID"
|
||||
- name: "startAt"
|
||||
type: "datetime"
|
||||
description: "开始答题时间"
|
||||
- name: "endAt"
|
||||
type: "datetime"
|
||||
description: "截止答题时间"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "创建时间"
|
||||
- name: "ExamTaskUser"
|
||||
description: "任务参与用户"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "关联唯一标识"
|
||||
- name: "taskId"
|
||||
type: "string"
|
||||
description: "任务ID"
|
||||
- name: "userId"
|
||||
type: "string"
|
||||
description: "用户ID"
|
||||
- name: "assignedAt"
|
||||
type: "datetime"
|
||||
description: "分派时间"
|
||||
|
||||
- name: "Quiz"
|
||||
description: "答题业务"
|
||||
entities:
|
||||
- name: "QuizRecord"
|
||||
description: "答题记录"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "记录唯一标识"
|
||||
- name: "userId"
|
||||
type: "string"
|
||||
description: "用户ID"
|
||||
- name: "totalScore"
|
||||
type: "integer"
|
||||
description: "总得分"
|
||||
- name: "correctCount"
|
||||
type: "integer"
|
||||
description: "正确题数"
|
||||
- name: "totalCount"
|
||||
type: "integer"
|
||||
description: "总题数"
|
||||
- name: "createdAt"
|
||||
type: "datetime"
|
||||
description: "答题时间"
|
||||
- name: "QuizAnswer"
|
||||
description: "单题答题详情"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "答案唯一标识"
|
||||
- name: "recordId"
|
||||
type: "string"
|
||||
description: "关联的答题记录ID"
|
||||
- name: "questionId"
|
||||
type: "string"
|
||||
description: "题目ID"
|
||||
- name: "userAnswer"
|
||||
type: "string"
|
||||
description: "用户提交的答案"
|
||||
- name: "isCorrect"
|
||||
type: "boolean"
|
||||
description: "是否正确"
|
||||
- name: "score"
|
||||
type: "integer"
|
||||
description: "该题得分"
|
||||
|
||||
- name: "System"
|
||||
description: "系统配置"
|
||||
entities:
|
||||
- name: "SystemConfig"
|
||||
description: "系统全局配置"
|
||||
attributes:
|
||||
- name: "id"
|
||||
type: "string"
|
||||
description: "配置唯一标识"
|
||||
- name: "configType"
|
||||
type: "string"
|
||||
description: "配置类型键"
|
||||
- name: "configValue"
|
||||
type: "string"
|
||||
description: "配置值 (通常为JSON字符串)"
|
||||
- name: "updatedAt"
|
||||
type: "datetime"
|
||||
description: "更新时间"
|
||||
|
||||
- name: "Backup"
|
||||
description: "数据备份与恢复"
|
||||
entities:
|
||||
- name: "BackupRestore"
|
||||
description: "数据导出与导入"
|
||||
attributes:
|
||||
- name: "dataType"
|
||||
type: "enum"
|
||||
values: ["users", "questions", "records", "answers"]
|
||||
description: "数据类型"
|
||||
- name: "action"
|
||||
type: "enum"
|
||||
values: ["export", "restore"]
|
||||
description: "操作类型"
|
||||
@@ -1,984 +0,0 @@
|
||||
openapi: "3.0.0"
|
||||
info:
|
||||
title: "Survey System API"
|
||||
version: "1.1.0"
|
||||
components:
|
||||
securitySchemes:
|
||||
AdminBearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
schemas:
|
||||
User:
|
||||
type: object
|
||||
required: ["id", "name", "phone", "createdAt"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
AdminUserView:
|
||||
type: object
|
||||
required: ["id", "name", "phone", "createdAt", "password"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
password:
|
||||
type: string
|
||||
description: "密码 (敏感字段;前端掩码显示)"
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
description: "所属用户组ID列表"
|
||||
UserGroup:
|
||||
type: object
|
||||
required: ["id", "name", "createdAt"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
isSystem:
|
||||
type: boolean
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
memberCount:
|
||||
type: integer
|
||||
description: "成员数量"
|
||||
QuestionCategory:
|
||||
type: object
|
||||
required: ["id", "name", "createdAt"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
Question:
|
||||
type: object
|
||||
required: ["content", "type", "answer", "score"]
|
||||
properties:
|
||||
content:
|
||||
type: string
|
||||
type:
|
||||
type: string
|
||||
enum: ["single", "multiple", "judgment", "text"]
|
||||
category:
|
||||
type: string
|
||||
description: "题目类别名称,缺省为通用"
|
||||
default: "通用"
|
||||
options:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
answer:
|
||||
oneOf:
|
||||
- type: string
|
||||
- type: array
|
||||
items:
|
||||
type: string
|
||||
score:
|
||||
type: number
|
||||
ExamSubject:
|
||||
type: object
|
||||
required: ["id", "name", "totalScore", "timeLimitMinutes", "typeRatios", "categoryRatios"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
totalScore:
|
||||
type: integer
|
||||
timeLimitMinutes:
|
||||
type: integer
|
||||
default: 60
|
||||
typeRatios:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
properties:
|
||||
single:
|
||||
type: number
|
||||
multiple:
|
||||
type: number
|
||||
judgment:
|
||||
type: number
|
||||
text:
|
||||
type: number
|
||||
categoryRatios:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: number
|
||||
ExamTask:
|
||||
type: object
|
||||
required: ["id", "name", "subjectId", "startAt", "endAt"]
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
subjectId:
|
||||
type: string
|
||||
startAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endAt:
|
||||
type: string
|
||||
format: date-time
|
||||
selectionConfig:
|
||||
type: string
|
||||
description: "JSON string storing original selection of userIds and groupIds"
|
||||
Pagination:
|
||||
type: object
|
||||
required: ["page", "limit", "total", "pages"]
|
||||
properties:
|
||||
page:
|
||||
type: integer
|
||||
limit:
|
||||
type: integer
|
||||
total:
|
||||
type: integer
|
||||
pages:
|
||||
type: integer
|
||||
paths:
|
||||
/api/users:
|
||||
post:
|
||||
summary: "创建用户或登录"
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name", "phone", "password"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: "用户姓名,2-20位中英文"
|
||||
phone:
|
||||
type: string
|
||||
description: "手机号"
|
||||
password:
|
||||
type: string
|
||||
description: "登录密码"
|
||||
responses:
|
||||
"200":
|
||||
description: "User created"
|
||||
|
||||
/api/users/{id}:
|
||||
get:
|
||||
summary: "获取用户信息"
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User details"
|
||||
|
||||
/api/questions/import:
|
||||
post:
|
||||
summary: "Excel导入题目"
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
"200":
|
||||
description: "Import result"
|
||||
|
||||
/api/questions:
|
||||
get:
|
||||
summary: "获取题目列表"
|
||||
parameters:
|
||||
- name: "type"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: ["single", "multiple", "judgment", "text"]
|
||||
- name: "category"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
- name: "page"
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: "limit"
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
description: "List of questions"
|
||||
post:
|
||||
summary: "添加单题"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Question"
|
||||
responses:
|
||||
"200":
|
||||
description: "Question created"
|
||||
|
||||
/api/questions/{id}:
|
||||
put:
|
||||
summary: "更新题目"
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Question"
|
||||
responses:
|
||||
"200":
|
||||
description: "Question updated"
|
||||
delete:
|
||||
summary: "删除题目"
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Question deleted"
|
||||
|
||||
/api/quiz/generate:
|
||||
post:
|
||||
summary: "生成随机试卷"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["userId", "subjectId"]
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
subjectId:
|
||||
type: string
|
||||
description: "考试科目ID"
|
||||
responses:
|
||||
"200":
|
||||
description: "Generated quiz"
|
||||
|
||||
/api/quiz/submit:
|
||||
post:
|
||||
summary: "提交答题"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["userId", "answers"]
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
answers:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: "Submission result"
|
||||
|
||||
/api/quiz/records/{userId}:
|
||||
get:
|
||||
summary: "获取用户答题记录"
|
||||
parameters:
|
||||
- name: "userId"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User quiz records"
|
||||
|
||||
/api/quiz/records/detail/{recordId}:
|
||||
get:
|
||||
summary: "获取答题记录详情"
|
||||
parameters:
|
||||
- name: "recordId"
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Record detail"
|
||||
|
||||
/api/admin/login:
|
||||
post:
|
||||
summary: "管理员登录"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["username", "password"]
|
||||
properties:
|
||||
username:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Login success"
|
||||
|
||||
/api/admin/statistics:
|
||||
get:
|
||||
summary: "获取统计数据"
|
||||
responses:
|
||||
"200":
|
||||
description: "Statistics data"
|
||||
|
||||
/api/admin/users:
|
||||
get:
|
||||
summary: "获取用户列表 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "page"
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: "limit"
|
||||
in: query
|
||||
schema:
|
||||
type: integer
|
||||
- name: "keyword"
|
||||
in: query
|
||||
schema:
|
||||
type: string
|
||||
description: "搜索关键词(姓名/手机)"
|
||||
responses:
|
||||
"200":
|
||||
description: "User list"
|
||||
post:
|
||||
summary: "创建用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name", "phone", "password"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User created"
|
||||
delete:
|
||||
summary: "删除用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["userId"]
|
||||
properties:
|
||||
userId:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Deleted"
|
||||
|
||||
/api/admin/users/{id}:
|
||||
put:
|
||||
summary: "更新用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
phone:
|
||||
type: string
|
||||
password:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User updated"
|
||||
|
||||
/api/admin/user-groups:
|
||||
get:
|
||||
summary: "获取用户组列表"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Group list"
|
||||
post:
|
||||
summary: "创建用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group created"
|
||||
|
||||
/api/admin/user-groups/{id}:
|
||||
put:
|
||||
summary: "更新用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group updated"
|
||||
delete:
|
||||
summary: "删除用户组"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group deleted"
|
||||
|
||||
/api/admin/user-groups/{id}/members:
|
||||
get:
|
||||
summary: "获取用户组成员"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Group members"
|
||||
|
||||
/api/admin/users/export:
|
||||
get:
|
||||
summary: "导出用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Export users"
|
||||
|
||||
/api/admin/users/import:
|
||||
post:
|
||||
summary: "导入用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
"200":
|
||||
description: "Import users"
|
||||
|
||||
/api/admin/users/{userId}/records:
|
||||
get:
|
||||
summary: "获取用户历史答题记录 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: userId
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User records"
|
||||
|
||||
/api/admin/question-categories:
|
||||
get:
|
||||
summary: "获取题目类别列表 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Category list"
|
||||
post:
|
||||
summary: "新增题目类别 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Created"
|
||||
|
||||
/api/admin/question-categories/{id}:
|
||||
put:
|
||||
summary: "更新题目类别 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Updated"
|
||||
delete:
|
||||
summary: "删除题目类别 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Deleted"
|
||||
|
||||
/api/admin/subjects:
|
||||
get:
|
||||
summary: "获取考试科目列表 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Subjects"
|
||||
post:
|
||||
summary: "新增考试科目 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExamSubject"
|
||||
responses:
|
||||
"200":
|
||||
description: "Created"
|
||||
|
||||
/api/admin/subjects/{id}:
|
||||
put:
|
||||
summary: "更新考试科目 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ExamSubject"
|
||||
responses:
|
||||
"200":
|
||||
description: "Updated"
|
||||
delete:
|
||||
summary: "删除考试科目 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Deleted"
|
||||
|
||||
/api/subjects:
|
||||
get:
|
||||
summary: "获取考试科目列表 (用户)"
|
||||
responses:
|
||||
"200":
|
||||
description: "Subjects"
|
||||
|
||||
/api/admin/tasks:
|
||||
get:
|
||||
summary: "获取考试任务列表 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Tasks"
|
||||
post:
|
||||
summary: "新增考试任务并分派用户 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
description: "Create task with mixed selection of users and groups (system handles de-duplication)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["name", "subjectId", "startAt", "endAt"]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
subjectId:
|
||||
type: string
|
||||
startAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endAt:
|
||||
type: string
|
||||
format: date-time
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Created"
|
||||
|
||||
/api/admin/tasks/{id}:
|
||||
put:
|
||||
summary: "更新考试任务 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
required: true
|
||||
description: "Update task with mixed selection (system handles de-duplication)"
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
subjectId:
|
||||
type: string
|
||||
startAt:
|
||||
type: string
|
||||
format: date-time
|
||||
endAt:
|
||||
type: string
|
||||
format: date-time
|
||||
userIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
groupIds:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Updated"
|
||||
delete:
|
||||
summary: "删除考试任务 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Deleted"
|
||||
|
||||
/api/admin/tasks/{id}/report:
|
||||
get:
|
||||
summary: "导出任务报表 (管理员)"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: id
|
||||
in: path
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Task report"
|
||||
|
||||
/api/admin/config:
|
||||
get:
|
||||
summary: "获取抽题配置"
|
||||
responses:
|
||||
"200":
|
||||
description: "Quiz config"
|
||||
put:
|
||||
summary: "更新抽题配置"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
responses:
|
||||
"200":
|
||||
description: "Config updated"
|
||||
|
||||
/api/admin/configs:
|
||||
get:
|
||||
summary: "获取所有系统配置"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "All configs"
|
||||
|
||||
/api/admin/password:
|
||||
put:
|
||||
summary: "修改管理员密码"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["oldPassword", "newPassword"]
|
||||
properties:
|
||||
oldPassword:
|
||||
type: string
|
||||
newPassword:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Password updated"
|
||||
|
||||
/api/admin/export/{type}:
|
||||
get:
|
||||
summary: "通用数据导出"
|
||||
description: "type: users, questions, records, answers"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "type"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
enum: ["users", "questions", "records", "answers"]
|
||||
responses:
|
||||
"200":
|
||||
description: "JSON export data"
|
||||
|
||||
/api/admin/restore:
|
||||
post:
|
||||
summary: "数据恢复"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
users:
|
||||
type: array
|
||||
questions:
|
||||
type: array
|
||||
records:
|
||||
type: array
|
||||
answers:
|
||||
type: array
|
||||
responses:
|
||||
"200":
|
||||
description: "Data restored"
|
||||
|
||||
/api/exam-tasks/user/{userId}:
|
||||
get:
|
||||
summary: "获取指定用户的考试任务"
|
||||
parameters:
|
||||
- name: "userId"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "User tasks"
|
||||
|
||||
/api/admin/tasks/{id}/users:
|
||||
get:
|
||||
summary: "获取任务分派的用户"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "id"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Task users"
|
||||
|
||||
/api/admin/active-tasks:
|
||||
get:
|
||||
summary: "获取当前活跃任务统计"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
responses:
|
||||
"200":
|
||||
description: "Active tasks stats"
|
||||
|
||||
/api/users/validate:
|
||||
post:
|
||||
summary: "验证用户信息(用于导入校验)"
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: ["phone"]
|
||||
properties:
|
||||
phone:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Validation result"
|
||||
|
||||
/api/users/name/{name}:
|
||||
get:
|
||||
summary: "根据姓名查找用户"
|
||||
security:
|
||||
- AdminBearerAuth: []
|
||||
parameters:
|
||||
- name: "name"
|
||||
in: "path"
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
description: "Users found"
|
||||
@@ -1,300 +0,0 @@
|
||||
database:
|
||||
type: "sqlite"
|
||||
version: "3"
|
||||
tables:
|
||||
- name: "users"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
constraints: "CHECK(length(name) >= 2 AND length(name) <= 20)"
|
||||
- name: "phone"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
constraints: "CHECK(length(phone) = 11 AND phone LIKE '1%' AND substr(phone, 2, 1) BETWEEN '3' AND '9')"
|
||||
- name: "password"
|
||||
type: "TEXT"
|
||||
comment: "用户登录密码 (当前版本为明文存储)"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_users_phone"
|
||||
columns: ["phone"]
|
||||
- name: "idx_users_created_at"
|
||||
columns: ["created_at"]
|
||||
|
||||
- name: "user_groups"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
- name: "description"
|
||||
type: "TEXT"
|
||||
- name: "is_system"
|
||||
type: "INTEGER"
|
||||
default: "0"
|
||||
comment: "是否为系统内置组 (0:否, 1:是)"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_user_groups_name"
|
||||
columns: ["name"]
|
||||
|
||||
- name: "user_group_members"
|
||||
columns:
|
||||
- name: "group_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "user_groups"
|
||||
column: "id"
|
||||
- name: "user_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "users"
|
||||
column: "id"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_user_group_members_unique"
|
||||
columns: ["group_id", "user_id"]
|
||||
unique: true
|
||||
|
||||
- name: "question_categories"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_question_categories_name"
|
||||
columns: ["name"]
|
||||
|
||||
- name: "questions"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "content"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
- name: "type"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
constraints: "CHECK(type IN ('single', 'multiple', 'judgment', 'text'))"
|
||||
- name: "category"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
default: "'通用'"
|
||||
comment: "题目类别名称 (无类别时默认通用)"
|
||||
- name: "options"
|
||||
type: "TEXT"
|
||||
comment: "JSON格式存储选项"
|
||||
- name: "answer"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
- name: "score"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
constraints: "CHECK(score > 0)"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_questions_type"
|
||||
columns: ["type"]
|
||||
- name: "idx_questions_category"
|
||||
columns: ["category"]
|
||||
- name: "idx_questions_score"
|
||||
columns: ["score"]
|
||||
|
||||
- name: "exam_subjects"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
- name: "total_score"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
constraints: "CHECK(total_score > 0)"
|
||||
- name: "time_limit_minutes"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
default: "60"
|
||||
constraints: "CHECK(time_limit_minutes > 0)"
|
||||
- name: "type_ratios"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
comment: "题型比重 JSON (single/multiple/judgment/text)"
|
||||
- name: "category_ratios"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
comment: "题目类别比重 JSON (categoryName->ratio)"
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
- name: "updated_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_exam_subjects_name"
|
||||
columns: ["name"]
|
||||
|
||||
- name: "exam_tasks"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "name"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
- name: "subject_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "exam_subjects"
|
||||
column: "id"
|
||||
- name: "start_at"
|
||||
type: "DATETIME"
|
||||
nullable: false
|
||||
- name: "end_at"
|
||||
type: "DATETIME"
|
||||
nullable: false
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_exam_tasks_subject_id"
|
||||
columns: ["subject_id"]
|
||||
- name: "idx_exam_tasks_start_at"
|
||||
columns: ["start_at"]
|
||||
- name: "idx_exam_tasks_end_at"
|
||||
columns: ["end_at"]
|
||||
|
||||
- name: "exam_task_users"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "task_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "exam_tasks"
|
||||
column: "id"
|
||||
- name: "user_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "users"
|
||||
column: "id"
|
||||
- name: "assigned_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_exam_task_users_task_id"
|
||||
columns: ["task_id"]
|
||||
- name: "idx_exam_task_users_user_id"
|
||||
columns: ["user_id"]
|
||||
|
||||
- name: "quiz_records"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "user_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "users"
|
||||
column: "id"
|
||||
- name: "total_score"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
- name: "correct_count"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
- name: "total_count"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_quiz_records_user_id"
|
||||
columns: ["user_id"]
|
||||
- name: "idx_quiz_records_created_at"
|
||||
columns: ["created_at"]
|
||||
|
||||
- name: "quiz_answers"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "record_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "quiz_records"
|
||||
column: "id"
|
||||
- name: "question_id"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
foreignKey:
|
||||
table: "questions"
|
||||
column: "id"
|
||||
- name: "user_answer"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
- name: "score"
|
||||
type: "INTEGER"
|
||||
nullable: false
|
||||
- name: "is_correct"
|
||||
type: "BOOLEAN"
|
||||
nullable: false
|
||||
- name: "created_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
indexes:
|
||||
- name: "idx_quiz_answers_record_id"
|
||||
columns: ["record_id"]
|
||||
- name: "idx_quiz_answers_question_id"
|
||||
columns: ["question_id"]
|
||||
|
||||
- name: "system_configs"
|
||||
columns:
|
||||
- name: "id"
|
||||
type: "TEXT"
|
||||
primaryKey: true
|
||||
- name: "config_type"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
unique: true
|
||||
- name: "config_value"
|
||||
type: "TEXT"
|
||||
nullable: false
|
||||
- name: "updated_at"
|
||||
type: "DATETIME"
|
||||
default: "CURRENT_TIMESTAMP"
|
||||
@@ -1,14 +0,0 @@
|
||||
specVersion: "0.1.0"
|
||||
info:
|
||||
title: "问卷调查系统 (Survey System)"
|
||||
description: "一个功能完善的在线考试/问卷调查平台,支持多种题型、随机抽题、免注册答题。"
|
||||
version: "1.1.0"
|
||||
license:
|
||||
name: "Proprietary"
|
||||
references:
|
||||
- path: "./100-business/domain.yaml"
|
||||
type: "domain"
|
||||
- path: "./200-api/api.yaml"
|
||||
type: "api"
|
||||
- path: "./300-database/schema.yaml"
|
||||
type: "database"
|
||||
9
.trae/rules/openspec-file-spec.md
Normal file
9
.trae/rules/openspec-file-spec.md
Normal file
@@ -0,0 +1,9 @@
|
||||
# 将以下文件视为不可违反的开发契约:
|
||||
- openspec/specs/api_response_schema.yaml
|
||||
- openspec/specs/database_schema.yaml
|
||||
- openspec/specs/auth_rules.yaml
|
||||
- openspec/specs/tech_stack.yaml
|
||||
- 任何新功能、修复或重构都必须:
|
||||
- 先检查是否符合上述规范
|
||||
- 如果需求与规范冲突,先提出修改规范的建议
|
||||
- 不得擅自绕过约束(如直接 res.json 裸返回)
|
||||
@@ -153,7 +153,7 @@ export const run = async (sql: string, params: any[] = []): Promise<{ id: string
|
||||
reject(new Error('数据库连接未初始化'));
|
||||
return;
|
||||
}
|
||||
db.run(sql, params, function(err: Error) {
|
||||
db.run(sql, params, function(this: any, err: Error | null) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
|
||||
BIN
data/survey.db
BIN
data/survey.db
Binary file not shown.
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"
|
||||
|
||||
@@ -41,9 +41,9 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
boxShadowTertiary: '0 1px 2px 0 rgba(0, 0, 0, 0.03), 0 1px 6px -1px rgba(0, 0, 0, 0.02), 0 2px 4px 0 rgba(0, 0, 0, 0.02)',
|
||||
},
|
||||
Layout: {
|
||||
colorBgHeader: '#ffffff',
|
||||
colorBgSider: '#ffffff',
|
||||
}
|
||||
headerBg: '#ffffff',
|
||||
siderBg: '#ffffff',
|
||||
},
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -329,7 +329,7 @@ const ExamSubjectPage = () => {
|
||||
{title: '操作',
|
||||
key: 'action',
|
||||
width: 100,
|
||||
align: 'left',
|
||||
align: 'left' as const,
|
||||
render: (_: any, record: ExamSubject) => (
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
@@ -617,4 +617,4 @@ const ExamSubjectPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamSubjectPage;
|
||||
export default ExamSubjectPage;
|
||||
|
||||
@@ -63,7 +63,7 @@ const ExamTaskPage = () => {
|
||||
setTasks(tasksRes.data);
|
||||
setSubjects(subjectsRes.data);
|
||||
setUsers(usersRes.data);
|
||||
setUserGroups(groupsRes);
|
||||
setUserGroups(groupsRes.data);
|
||||
} catch (error) {
|
||||
message.error('获取数据失败');
|
||||
} finally {
|
||||
@@ -86,10 +86,11 @@ const ExamTaskPage = () => {
|
||||
for (const gid of selectedGroupIds) {
|
||||
if (!groupMembersMap[gid]) {
|
||||
try {
|
||||
const members = await userGroupAPI.getMembers(gid);
|
||||
const membersRes = await userGroupAPI.getMembers(gid);
|
||||
const members = (membersRes as any).data as any[];
|
||||
setGroupMembersMap(prev => ({
|
||||
...prev,
|
||||
[gid]: members.map((u: any) => u.id)
|
||||
[gid]: (members || []).map((u: any) => u.id)
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch members for group ${gid}`, e);
|
||||
@@ -383,10 +384,7 @@ const ExamTaskPage = () => {
|
||||
placeholder="请选择考试科目"
|
||||
style={{ width: '100%' }}
|
||||
showSearch
|
||||
filterOption={(input, option) => {
|
||||
const value = option?.children as string;
|
||||
return value.toLowerCase().includes(input.toLowerCase());
|
||||
}}
|
||||
optionFilterProp="children"
|
||||
dropdownStyle={{ maxHeight: 300, overflow: 'auto' }}
|
||||
virtual
|
||||
>
|
||||
@@ -463,10 +461,9 @@ const ExamTaskPage = () => {
|
||||
showSearch
|
||||
optionLabelProp="label"
|
||||
filterOption={(input, option) => {
|
||||
const label = option?.label as string;
|
||||
if (label && label.toLowerCase().includes(input.toLowerCase())) return true;
|
||||
const children = React.Children.toArray(option?.children).join('');
|
||||
return children.toLowerCase().includes(input.toLowerCase());
|
||||
const inputLower = input.toLowerCase();
|
||||
const labelText = String((option as any)?.label ?? '').toLowerCase();
|
||||
return labelText.includes(inputLower);
|
||||
}}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
virtual
|
||||
@@ -534,4 +531,4 @@ const ExamTaskPage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default ExamTaskPage;
|
||||
export default ExamTaskPage;
|
||||
|
||||
@@ -57,7 +57,7 @@ const QuestionManagePage = () => {
|
||||
const [searchType, setSearchType] = useState<string>('');
|
||||
const [searchCategory, setSearchCategory] = useState<string>('');
|
||||
const [searchKeyword, setSearchKeyword] = useState<string>('');
|
||||
const [searchDateRange, setSearchDateRange] = useState<[dayjs.Dayjs, dayjs.Dayjs] | null>(null);
|
||||
const [searchDateRange, setSearchDateRange] = useState<[dayjs.Dayjs | null, dayjs.Dayjs | null] | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: 10,
|
||||
@@ -91,9 +91,10 @@ const QuestionManagePage = () => {
|
||||
}));
|
||||
|
||||
// 提取并更新可用的题型和类别列表
|
||||
const allQuestions = await questionAPI.getQuestions({ limit: 10000 });
|
||||
const types = [...new Set(allQuestions.data.map((q: any) => q.type))];
|
||||
const categories = [...new Set(allQuestions.data.map((q: any) => q.category || '通用'))];
|
||||
const allQuestionsRes = await questionAPI.getQuestions({ limit: 10000 });
|
||||
const allList = ((allQuestionsRes as any).data as any[]) || [];
|
||||
const types = [...new Set(allList.map((q: any) => String(q.type)))];
|
||||
const categories = [...new Set(allList.map((q: any) => String(q.category || '通用')))];
|
||||
setAvailableTypes(types);
|
||||
setAvailableCategories(categories);
|
||||
} catch (error: any) {
|
||||
@@ -668,4 +669,4 @@ const QuestionManagePage = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default QuestionManagePage;
|
||||
export default QuestionManagePage;
|
||||
|
||||
@@ -59,13 +59,13 @@ api.interceptors.response.use(
|
||||
export const userAPI = {
|
||||
createUser: (data: { name: string; phone: string; password?: string }) => api.post('/users', data),
|
||||
getUser: (id: string) => api.get(`/users/${id}`),
|
||||
validateUserInfo: (data: { name: string; phone: string }) => api.post('/users/validate', data),
|
||||
validateUserInfo: (data: { name: string; phone: string; password?: string }) => api.post('/users/validate', data),
|
||||
getUsersByName: (name: string) => api.get(`/users/name/${name}`),
|
||||
};
|
||||
|
||||
// 题目相关API
|
||||
export const questionAPI = {
|
||||
getQuestions: (params?: { type?: string; page?: number; limit?: number }) =>
|
||||
getQuestions: (params?: { type?: string; category?: string; keyword?: string; startDate?: string; endDate?: string; page?: number; limit?: number }) =>
|
||||
api.get('/questions', { params }),
|
||||
getQuestion: (id: string) => api.get(`/questions/${id}`),
|
||||
createQuestion: (data: any) => api.post('/questions', data),
|
||||
|
||||
166
测试报告.md
166
测试报告.md
@@ -1,140 +1,50 @@
|
||||
# 问卷调查系统 - 功能测试报告
|
||||
# 问卷/考试系统 - UI 功能测试报告
|
||||
|
||||
## 测试范围
|
||||
- 用户端:`/`、`/subjects`、`/tasks`、`/quiz`、`/result/:id`
|
||||
- 管理端:`/admin/login`、`/admin/dashboard`、`/admin/questions`、`/admin/categories`、`/admin/subjects`、`/admin/tasks`、`/admin/users`(含用户组管理)、`/admin/statistics`、`/admin/backup`、`/admin/config`
|
||||
- 说明:用户组功能(用户组管理、成员关系、考试任务按组分派与混合选择)已实现但目前标记为“未测试”。
|
||||
|
||||
## 测试环境
|
||||
- 操作系统:Windows
|
||||
- 浏览器:Chrome/Edge/Firefox
|
||||
- 测试时间:2024年12月17日
|
||||
- 前端开发地址:`http://localhost:5173/`
|
||||
- 后端开发地址:`http://localhost:3001/api`
|
||||
- 测试时间:2025-12-22
|
||||
|
||||
## 功能测试结果
|
||||
## 测试方法
|
||||
- 静态代码审查:对页面路由、接口调用、关键交互与边界条件进行逐页核对
|
||||
- 冒烟检查:启动开发服务后,验证前后端可启动、接口可访问(以实际执行记录为准)
|
||||
|
||||
### ✅ 用户端功能测试
|
||||
## 结果概览
|
||||
- 管理端:页面路由与菜单整体完整,但存在少量可达性与安全性问题
|
||||
- 用户端:存在多处关键交互链路断裂风险(会导致“无法加载科目/任务/无法开始考试/考试页空白”)
|
||||
- 用户组:实现已落地但缺少覆盖性验证与审计日志能力,需单独做回归测试
|
||||
|
||||
#### 1. 用户注册与验证
|
||||
- [x] 姓名验证(2-20字符,中英文)
|
||||
- [x] 手机号验证(11位,1开头,第二位3-9)
|
||||
- [x] 错误提示显示
|
||||
- [x] 重复手机号处理
|
||||
## 关键缺陷(建议优先级从高到低)
|
||||
|
||||
#### 2. 答题功能
|
||||
- [x] 随机抽题算法
|
||||
- [x] 单选题展示与答题
|
||||
- [x] 多选题展示与答题
|
||||
- [x] 判断题展示与答题
|
||||
- [x] 文字描述题展示与答题
|
||||
- [x] 答题进度显示
|
||||
- [x] 答案提交与评分
|
||||
- [x] 答题结果展示
|
||||
### P0:用户端关键流程可能不可用
|
||||
- 用户信息状态来源不一致:`HomePage` 使用 `UserContext` 写入 `localStorage(survey_user)`,但 `SubjectSelectionPage` 与 `UserTaskPage` 使用 `zustand` 的 `useUserStore` 且未从 `localStorage` 恢复,导致 `user?.id` 可能为空,后续生成试卷/拉取任务会失败(见 `src/pages/HomePage.tsx:18`、`src/stores/userStore.ts:16`、`src/pages/SubjectSelectionPage.tsx:38`、`src/pages/UserTaskPage.tsx:28`)。
|
||||
- API 响应对象使用方式不一致:`src/utils/request.ts` 的响应拦截器会直接返回 `{ success, data }`,但 `SubjectSelectionPage`、`UserTaskPage` 仍按 `axios` 原始响应访问 `response.data.success`,会导致列表不渲染(见 `src/utils/request.ts:14`、`src/pages/SubjectSelectionPage.tsx:55`、`src/pages/UserTaskPage.tsx:39`)。
|
||||
- 考试页初始化逻辑可能清空题目:`QuizPage` 在设置题目后调用 `clearQuiz()`,而 `clearQuiz()` 会清空 `questions`,可能导致考试页无题可答(见 `src/pages/QuizPage.tsx:51`、`src/contexts/QuizContext.tsx:38`)。
|
||||
|
||||
#### 3. 界面与体验
|
||||
- [x] 响应式设计(移动端适配)
|
||||
- [x] 加载状态显示
|
||||
- [x] 错误处理与提示
|
||||
- [x] 页面导航流畅
|
||||
### P1:管理端配置入口可达性问题
|
||||
- 存在 `/admin/config` 路由,但侧边栏菜单未提供入口,用户只能手工输入 URL 访问(见 `src/App.tsx:58`、`src/layouts/AdminLayout.tsx:29`)。
|
||||
|
||||
### ✅ 管理端功能测试
|
||||
### P1:鉴权/敏感信息呈现风险(影响上线)
|
||||
- `adminAuth` 为简化放行逻辑,接口层面的“权限控制”在生产环境不成立(见 `api/middlewares/index.ts` 中 `adminAuth` 实现)。
|
||||
- 用户密码存在明文存储/展示路径(例如用户管理页面有显示/隐藏密码的 UI),属于上线前必须整改的安全项(见 `src/pages/admin/UserManagePage.tsx:36`)。
|
||||
|
||||
#### 1. 管理员登录
|
||||
- [x] 用户名密码验证
|
||||
- [x] 登录状态保持
|
||||
- [x] 权限控制
|
||||
### P2:一致性与可维护性问题(影响后续扩展)
|
||||
- 同一功能域存在两套请求封装(`src/services/api.ts` 与 `src/utils/request.ts`)且返回值约定不同,容易造成新页面复用错误(已在用户端出现)。
|
||||
- 测试脚本与端口配置不一致:`test/*.js` 默认指向 `http://localhost:3000/api`,与当前后端默认端口 `3001` 不一致,直接运行会误判失败(见 `test/test_openspec_features.js:3`)。
|
||||
|
||||
#### 2. 题库管理
|
||||
- [x] Excel文件导入
|
||||
- [x] 题目增删改查
|
||||
- [x] 题型筛选
|
||||
- [x] 数据验证
|
||||
## 建议完善(按收益排序)
|
||||
- 统一用户态管理:用户端统一使用 `UserContext` 或统一使用 `zustand`,并保证从 `localStorage` 恢复与退出清理一致。
|
||||
- 统一请求封装与返回类型:优先减少到一个请求层(或保证两者对外返回形态一致),避免页面端误用。
|
||||
- 修复考试页初始化顺序:确保“清理旧状态”发生在“写入新题目”之前,或仅清理 `answers` 不清理 `questions`。
|
||||
- 管理端补齐菜单入口:为 `/admin/config` 提供侧边栏入口,避免功能“存在但不可见”。
|
||||
- 用户组做专项回归:覆盖“全体用户”系统组约束、成员增删、任务按组分派、混合选择去重、报表导出。
|
||||
|
||||
#### 3. 抽题配置
|
||||
- [x] 题型比例设置
|
||||
- [x] 总分配置
|
||||
- [x] 比例总和验证
|
||||
- [x] 实时预览
|
||||
|
||||
#### 4. 数据统计
|
||||
- [x] 用户统计
|
||||
- [x] 答题记录统计
|
||||
- [x] 题型正确率分析
|
||||
- [x] 数据可视化图表
|
||||
- [x] 数据导出功能
|
||||
|
||||
#### 5. 数据备份与恢复
|
||||
- [x] 数据备份(Excel格式)
|
||||
- [x] 数据恢复
|
||||
- [x] 备份文件下载
|
||||
|
||||
### ✅ 技术功能测试
|
||||
|
||||
#### 1. 数据库
|
||||
- [x] SQLite数据库连接
|
||||
- [x] 数据表创建与初始化
|
||||
- [x] 外键约束
|
||||
- [x] 索引优化
|
||||
|
||||
#### 2. API接口
|
||||
- [x] RESTful API设计
|
||||
- [x] 请求参数验证
|
||||
- [x] 错误处理
|
||||
- [x] 响应格式化
|
||||
|
||||
#### 3. 文件处理
|
||||
- [x] Excel文件读取
|
||||
- [x] 文件上传下载
|
||||
- [x] 文件格式验证
|
||||
- [x] 大文件处理
|
||||
|
||||
## 性能测试结果
|
||||
|
||||
### 响应时间
|
||||
- 页面加载:< 1秒
|
||||
- API响应:< 500ms
|
||||
- 文件上传:< 3秒(10MB文件)
|
||||
|
||||
### 并发处理
|
||||
- 同时在线用户:> 100人
|
||||
- 并发请求处理:正常
|
||||
|
||||
## 兼容性测试
|
||||
|
||||
### 浏览器兼容性
|
||||
- [x] Chrome 120+
|
||||
- [x] Edge 120+
|
||||
- [x] Firefox 120+
|
||||
- [x] Safari 15+
|
||||
|
||||
### 设备兼容性
|
||||
- [x] 桌面端(1920x1080)
|
||||
- [x] 平板端(768x1024)
|
||||
- [x] 手机端(375x667)
|
||||
|
||||
## 安全测试结果
|
||||
|
||||
### 输入验证
|
||||
- [x] SQL注入防护
|
||||
- [x] XSS攻击防护
|
||||
- [x] 文件上传安全检查
|
||||
- [x] 参数类型验证
|
||||
|
||||
### 权限控制
|
||||
- [x] 管理员权限验证
|
||||
- [x] 用户数据隔离
|
||||
- [x] 接口访问控制
|
||||
|
||||
## 测试总结
|
||||
|
||||
### 通过的功能
|
||||
所有核心功能均已通过测试,系统运行稳定,用户体验良好。
|
||||
|
||||
### 发现的问题
|
||||
1. 部分图表在移动端显示需要优化
|
||||
2. 大文件上传时的进度提示可以改进
|
||||
3. 数据恢复时的冲突处理需要完善
|
||||
|
||||
### 建议改进
|
||||
1. 添加更多数据可视化图表
|
||||
2. 支持批量操作功能
|
||||
3. 增加系统日志记录
|
||||
4. 优化移动端交互体验
|
||||
|
||||
## 结论
|
||||
|
||||
问卷调查系统功能完善,性能良好,符合设计要求,可以投入生产使用。建议在生产环境中进行小规模试运行,收集用户反馈后进行进一步优化。
|
||||
## 建议回归用例清单(手工)
|
||||
- 用户端:新用户登录→进入科目页→选择科目→开始考试→答题提交→结果页展示→返回首页→查看我的任务
|
||||
- 管理端:登录→题库(导入/新增/编辑/删除/导出)→类别管理→科目配置→任务创建(选用户+选用户组混合)→查看任务报表→用户管理(分组)→备份导出/恢复
|
||||
|
||||
Reference in New Issue
Block a user