diff --git a/.opencode/100-business/domain.yaml b/.opencode/100-business/domain.yaml deleted file mode 100644 index 70c4748..0000000 --- a/.opencode/100-business/domain.yaml +++ /dev/null @@ -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: "操作类型" diff --git a/.opencode/200-api/api.yaml b/.opencode/200-api/api.yaml deleted file mode 100644 index e263fe7..0000000 --- a/.opencode/200-api/api.yaml +++ /dev/null @@ -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" diff --git a/.opencode/300-database/schema.yaml b/.opencode/300-database/schema.yaml deleted file mode 100644 index 0d6635a..0000000 --- a/.opencode/300-database/schema.yaml +++ /dev/null @@ -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" diff --git a/.opencode/manifest.yaml b/.opencode/manifest.yaml deleted file mode 100644 index a09a9e9..0000000 --- a/.opencode/manifest.yaml +++ /dev/null @@ -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" diff --git a/.trae/rules/openspec-file-spec.md b/.trae/rules/openspec-file-spec.md new file mode 100644 index 0000000..08bcfdb --- /dev/null +++ b/.trae/rules/openspec-file-spec.md @@ -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 裸返回) \ No newline at end of file diff --git a/api/database/index.ts b/api/database/index.ts index cfac7fa..5d8cdc1 100644 --- a/api/database/index.ts +++ b/api/database/index.ts @@ -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 { diff --git a/data/survey.db b/data/survey.db index e418bd0..bfe6a92 100644 Binary files a/data/survey.db and b/data/survey.db differ diff --git a/openspec/changes/add-custom-focus-duration/proposal.md b/openspec/changes/add-custom-focus-duration/proposal.md new file mode 100644 index 0000000..6a805a0 --- /dev/null +++ b/openspec/changes/add-custom-focus-duration/proposal.md @@ -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`)以便统计与审计? +- 超阈值动作是否需要支持可配置(仅警告 / 记录违规 / 自动交卷)? + diff --git a/openspec/changes/add-custom-focus-duration/specs/quiz-integrity/spec.md b/openspec/changes/add-custom-focus-duration/specs/quiz-integrity/spec.md new file mode 100644 index 0000000..3554835 --- /dev/null +++ b/openspec/changes/add-custom-focus-duration/specs/quiz-integrity/spec.md @@ -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 自动提交该次考试 + diff --git a/openspec/changes/add-custom-focus-duration/tasks.md b/openspec/changes/add-custom-focus-duration/tasks.md new file mode 100644 index 0000000..1c2e26f --- /dev/null +++ b/openspec/changes/add-custom-focus-duration/tasks.md @@ -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` + diff --git a/openspec/changes/fix-quizpage-useeffect-bug/proposal.md b/openspec/changes/fix-quizpage-useeffect-bug/proposal.md deleted file mode 100644 index 27e198d..0000000 --- a/openspec/changes/fix-quizpage-useeffect-bug/proposal.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md b/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md deleted file mode 100644 index a98e437..0000000 --- a/openspec/changes/fix-quizpage-useeffect-bug/specs/quiz/spec.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/openspec/changes/fix-quizpage-useeffect-bug/tasks.md b/openspec/changes/fix-quizpage-useeffect-bug/tasks.md deleted file mode 100644 index 18f69b8..0000000 --- a/openspec/changes/fix-quizpage-useeffect-bug/tasks.md +++ /dev/null @@ -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并显示题目 \ No newline at end of file diff --git a/openspec/changes/user-group-management/proposal.md b/openspec/changes/user-group-management/proposal.md deleted file mode 100644 index 4555761..0000000 --- a/openspec/changes/user-group-management/proposal.md +++ /dev/null @@ -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 diff --git a/openspec/changes/user-group-management/specs/user-group/spec.md b/openspec/changes/user-group-management/specs/user-group/spec.md deleted file mode 100644 index 2ba222d..0000000 --- a/openspec/changes/user-group-management/specs/user-group/spec.md +++ /dev/null @@ -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**. diff --git a/openspec/project.md b/openspec/project.md index 3da5119..dac66bd 100644 --- a/openspec/project.md +++ b/openspec/project.md @@ -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 --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//`)描述,再进入实现。 ## 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 规范中补充依赖与接口约束。 diff --git a/openspec/specs/api_response_schema.yaml b/openspec/specs/api_response_schema.yaml new file mode 100644 index 0000000..60fc15e --- /dev/null +++ b/openspec/specs/api_response_schema.yaml @@ -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: }` + - 控制器自行返回 `{ 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" + diff --git a/openspec/specs/auth_rules.yaml b/openspec/specs/auth_rules.yaml new file mode 100644 index 0000000..052becc --- /dev/null +++ b/openspec/specs/auth_rules.yaml @@ -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`,不具备会话隔离与过期能力。 + diff --git a/openspec/specs/database_schema.yaml b/openspec/specs/database_schema.yaml new file mode 100644 index 0000000..b009fe2 --- /dev/null +++ b/openspec/specs/database_schema.yaml @@ -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 建表语句未包含该列。 + diff --git a/openspec/specs/nfr.yaml b/openspec/specs/nfr.yaml new file mode 100644 index 0000000..e43c5ec --- /dev/null +++ b/openspec/specs/nfr.yaml @@ -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 + diff --git a/openspec/specs/tech_stack.yaml b/openspec/specs/tech_stack.yaml new file mode 100644 index 0000000..e7a61dd --- /dev/null +++ b/openspec/specs/tech_stack.yaml @@ -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" + diff --git a/src/main.tsx b/src/main.tsx index b05cf2e..91261ea 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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', + }, } }} > diff --git a/src/pages/admin/ExamSubjectPage.tsx b/src/pages/admin/ExamSubjectPage.tsx index f390be5..f716405 100644 --- a/src/pages/admin/ExamSubjectPage.tsx +++ b/src/pages/admin/ExamSubjectPage.tsx @@ -329,7 +329,7 @@ const ExamSubjectPage = () => { {title: '操作', key: 'action', width: 100, - align: 'left', + align: 'left' as const, render: (_: any, record: ExamSubject) => (