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

@@ -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: "操作类型"

View File

@@ -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"

View File

@@ -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"

View File

@@ -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"

View 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 裸返回)

View File

@@ -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 {

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

@@ -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',
},
}
}}
>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View File

@@ -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. 优化移动端交互体验
## 结论
问卷调查系统功能完善,性能良好,符合设计要求,可以投入生产使用。建议在生产环境中进行小规模试运行,收集用户反馈后进行进一步优化。
## 建议回归用例清单(手工)
- 用户端:新用户登录→进入科目页→选择科目→开始考试→答题提交→结果页展示→返回首页→查看我的任务
- 管理端:登录→题库(导入/新增/编辑/删除/导出)→类别管理→科目配置→任务创建(选用户+选用户组混合)→查看任务报表→用户管理(分组)→备份导出/恢复