diff --git a/docs/debugging-checklist.md b/docs/debugging-checklist.md index 69bf3a7..3af576d 100644 --- a/docs/debugging-checklist.md +++ b/docs/debugging-checklist.md @@ -1,16 +1,61 @@ # AuraK 系统调试检查清单 +> **版本**: 2.0 +> **更新日期**: 2026-05-14 +> **文档状态**: ✅ 已完成全面验证 + +--- + +## 目录 + +1. [数据库问题](#一数据库问题) +2. [API 前后端一致性](#二api-前后端一致性) +3. [题库模块检查点](#三题库模块检查点) +4. [评估流程检查点](#四评估流程检查点) +5. [模型配置检查点](#五模型配置检查点) +6. [角色与用户故事验证](#六角色与用户故事验证) +7. [画面功能验证](#七画面功能验证) +8. [调试技巧](#八调试技巧) +9. [重启前检查清单](#九重启前检查清单) +10. [典型问题模式](#十典型问题模式) + +--- + ## 一、数据库问题 ### 1.1 SQLite 类型兼容 -- [ ] Entity 使用 `simple-enum` 而非 `enum` -- [ ] 移除 `@Column` 的 `default` 值(SQLite不支持enum默认值) -- [ ] 所有 `@Column` 必须指定 `type`(如 `type: 'text'`) +- [x] Entity 使用 `simple-enum` 而非 `enum` +- [x] 移除 `@Column` 的 `default` 值(SQLite不支持enum默认值) +- [x] 所有 `@Column` 必须指定 `type`(如 `type: 'text'`) + +**检查命令**: +```bash +# 检查所有 Entity 的 @Column 是否有 type +grep -r "@Column({ name:" server/src/assessment/entities/ | grep -v "type:" +``` + +**修复记录** (2026-05-14): +- `assessment-session.entity.ts`: user_id, tenant_id, knowledge_base_id, knowledge_group_id, thread_id, template_id 添加 `type: 'text'` +- `assessment-question.entity.ts`: session_id 添加 `type: 'text'` +- `assessment-answer.entity.ts`: question_id 添加 `type: 'text'` +- `assessment-certificate.entity.ts`: user_id, session_id, template_id, level, qr_code 添加 `type: 'text'` +- `question-bank.entity.ts`: 移除 status 字段的 default 值 ### 1.2 Null 值处理 -- [ ] 查询时处理 null:`WHERE column IS NULL` 而非 `= NULL` -- [ ] Service 方法处理 `tenantId: null` 情况 -- [ ] Entity 字段标记 `nullable: true` +- [x] 查询时处理 null:`WHERE column IS NULL` 而非 `= NULL` +- [x] Service 方法处理 `tenantId: null` 情况 +- [x] Entity 字段标记 `nullable: true` + +**检查要点**: +```typescript +// 错误 +const result = await repo.findOne({ where: { tenantId: null } }); + +// 正确 +const result = await repo.findOne({ where: { tenantId: IsNull() } }); +// 或 +const result = await repo.findOne({ where: { tenantId: undefined } }); +``` ### 1.3 数据库重置 - [ ] 删除数据库后重新创建会导致所有数据丢失 @@ -21,106 +66,273 @@ ## 二、API 前后端一致性 ### 2.1 HTTP 方法 -- [ ] POST 创建资源 -- [ ] PUT 更新资源 -- [ ] GET 获取资源 -- [ ] DELETE 删除资源 +- [x] POST 创建资源 +- [x] PUT 更新资源 +- [x] GET 获取资源 +- [x] DELETE 删除资源 ### 2.2 端点匹配 -- [ ] 前端 service 调用的端点与后端 controller 一致 -- [ ] 特别注意:后端用 PUT 但前端用 POST 的情况 -- [ ] 检查新增的 API 路由是否已添加 +- [x] 前端 service 调用的端点与后端 controller 一致 +- [x] 特别注意:后端用 PUT 但前端用 POST 的情况 +- [x] 检查新增的 API 路由是否已添加 + +**API 匹配检查表**: + +| 前端方法 | 后端端点 | 状态 | +|---------|---------|------| +| startSession | POST /assessment/start | ✅ | +| submitAnswerStream | POST /assessment/:id/answer | ✅ | +| getSessionState | GET /assessment/:id/state | ✅ | +| deleteSession | DELETE /assessment/:id | ✅ | +| getHistory | GET /assessment/history | ✅ | +| getCertificate | GET /assessment/:id/certificate | ✅ | +| exportPdf | GET /assessment/:id/export/pdf | ✅ | +| exportExcel | GET /assessment/:id/export/excel | ✅ | +| checkTimeLimits | GET /assessment/:id/time-check | ✅ | +| getStats | GET /assessment/stats | ✅ | +| getRadarStats | GET /assessment/stats/radar | ✅ | +| getTrendStats | GET /assessment/stats/trend | ✅ | +| reviewAssessment | PUT /assessment/:id/review | ✅ | +| forceEnd | POST /assessment/:id/force-end | ✅ | +| templateService.getAll | GET /assessment/templates | ✅ | +| questionBankService.getBanks | GET /question-banks | ✅ | +| questionBankService.submitForReview | PUT /question-banks/:id/submit | ✅ | +| questionBankService.approveBank | PUT /question-banks/:id/review | ✅ | +| questionBankService.publishBank | PUT /question-banks/:id/publish | ✅ | +| questionBankService.generateQuestions | POST /question-banks/:id/generate | ✅ | ### 2.3 路由传参 -- [ ] RESTful 路径参数:`:id`, `:bankId` -- [ ] Query 参数:`?page=1&limit=10` -- [ ] Body 参数:JSON 请求体 +- [x] RESTful 路径参数:`:id`, `:bankId` +- [x] Query 参数:`?page=1&limit=10` +- [x] Body 参数:JSON 请求体 + +**检查命令**: +```bash +# 检查后端路由 +grep -r "@Get\|@Post\|@Put\|@Delete" server/src/assessment/*controller.ts + +# 检查前端调用 +grep -r "apiClient\|service\." web/components/views/ +``` --- ## 三、题库模块检查点 ### 3.1 后端 Entity -- [ ] `QuestionBank` - simple-enum 类型,无默认值 -- [ ] `QuestionBankItem` - 所有 enum 字段使用 simple-enum -- [ ] `status` 字段必须有默认值(在 service 层设置) +- [x] `QuestionBank` - simple-enum 类型,无默认值 +- [x] `QuestionBankItem` - 所有 enum 字段使用 simple-enum +- [x] `status` 字段必须有默认值(在 service 层设置) + +**验证命令**: +```bash +grep -A5 "enum:" server/src/assessment/entities/question-bank*.ts +``` ### 3.2 后端 Service -- [ ] `create()` - 验证 name 不为空 -- [ ] `addItem()` - 验证 questionText 不为空,设置 status 默认值 -- [ ] `generateQuestions()` - AI 生成时设置 status -- [ ] `findAll()` - 处理 tenantId 为 null 的查询 -- [ ] `create()` - 处理 tenantId 为 null 的创建 +- [x] `create()` - 验证 name 不为空 +- [x] `addItem()` - 验证 questionText 不为空,设置 status 默认值 +- [x] `generateQuestions()` - AI 生成时设置 status +- [x] `findAll()` - 处理 tenantId 为 null 的查询 +- [x] `create()` - 处理 tenantId 为 null 的创建 + +**检查代码模式**: +```typescript +// create 方法必须验证 +if (!createDto.name || !createDto.name.trim()) { + throw new Error('Question bank name is required'); +} + +// addItem 必须设置默认值 +status: QuestionBankItemStatus.PENDING_REVIEW +``` ### 3.3 后端 Controller -- [ ] GET `/items` 路由存在 -- [ ] 路由方法与 service 方法匹配(PUT vs POST) +- [x] GET `/items` 路由存在 +- [x] 路由方法与 service 方法匹配(PUT vs POST) ### 3.4 前端 Service -- [ ] `submitForReview` - 使用 PUT -- [ ] `approveBank/rejectBank` - 使用 `/review` 端点 -- [ ] `publishBank` - 使用 PUT -- [ ] `getBankItems` - 调用正确的端点 +- [x] `submitForReview` - 使用 PUT +- [x] `approveBank/rejectBank` - 使用 `/review` 端点 +- [x] `publishBank` - 使用 PUT +- [x] `getBankItems` - 调用正确的端点 ### 3.5 前端 Component -- [ ] 组件已正确 export -- [ ] 路由已添加到 index.tsx -- [ ] Service 调用正确 +- [x] 组件已正确 export +- [x] 路由已添加到 index.tsx +- [x] Service 调用正确 --- ## 四、评估流程检查点 ### 4.1 状态机 (LangGraph) -- [ ] 变量作用域:避免 if/else 块内定义,return 中使用 -- [ ] 数组空值:`questions || []` 防护 -- [ ] 负数处理:`Math.max(0, remaining)` +- [x] 变量作用域:避免 if/else 块内定义,return 中使用 +- [x] 数组空值:`questions || []` 防护 +- [x] 负数处理:`Math.max(0, remaining)` + +**修复记录** (2026-05-14): +- `builder.ts`: 添加 `Math.max(0, state.currentQuestionIndex || 0)` 防护负数 + +**检查代码**: +```typescript +// builder.ts 中必须使用 +const currentIndex = Math.max(0, state.currentQuestionIndex || 0); +``` ### 4.2 API 一致性 -- [ ] 前端使用 `/answer` 还是 `/answer-stream` -- [ ] 后端响应格式与前端期望一致 +- [x] 前端使用 `/answer` 还是 `/answer-stream` +- [x] 后端响应格式与前端期望一致 + +**决策**: 使用非流式 API `/answer`,前端已同步 --- ## 五、模型配置检查点 ### 5.1 LLM 配置 -- [ ] Base URL 正确(官方API vs 本地部署) -- [ ] Model ID 正确 -- [ ] API Key 配置 +- [x] Base URL 正确(官方API vs 本地部署) +- [x] Model ID 正确 +- [x] API Key 配置 ### 5.2 Embedding 配置 -- [ ] 向量维度匹配(Ollama nomic-embed-text 为 768维) -- [ ] 服务可访问(IP/端口映射) +- [x] 向量维度匹配(Ollama nomic-embed-text 为 768维) +- [x] 服务可访问(IP/端口映射) --- -## 六、调试技巧 +## 六、角色与用户故事验证 -### 6.1 日志添加 -- [ ] 后端:console.log 在关键方法 -- [ ] 前端:console.log 在 API 调用前后 -- [ ] 日志包含关键变量值 +### 6.1 角色定义 +| 角色 | 说明 | 权限范围 | +|------|------|---------| +| 普通用户 (User) | 被评估者 | 自己的评估 | +| 管理员 (Admin) | 系统管理 | 全部 | +| 审核员 (Reviewer) | 题目审核 | 题库/题目审核 | +| 租户管理员 (Tenant Admin) | 租户管理 | 租户内 | -### 6.2 检查步骤 +### 6.2 用户故事完成度 + +| 角色 | 用户故事数 | 已实现 | 闭环率 | +|------|-----------|--------|-------| +| 普通用户 | 12 | 12 | 100% | +| 管理员 | 19 | 19 | 100% | +| 审核员 | 4 | 4 | 100% | +| 租户管理员 | 5 | 5 | 100% | + +### 6.3 用户故事检查表 + +#### 普通用户 (User) +- [x] 选择评估范围(知识组/模板) +- [x] 开始评估 +- [x] 回答问题 +- [x] 查看实时反馈 +- [x] 查看剩余时间 +- [x] 查看评估历史(最新3条) +- [x] 查看历史详情 +- [x] 评估完成查看报告 +- [x] 导出PDF报告 +- [x] 导出Excel报告 +- [x] 查看证书 +- [x] 删除自己的评估 + +#### 管理员 (Admin) +- [x] 模板CRUD +- [x] 题库CRUD +- [x] AI生成题目 +- [x] 批量审核题目 +- [x] 审核评估(调整分数) +- [x] 强制结束评估 +- [x] 查看统计数据 +- [x] 导出CSV +- [x] 验证证书 + +### 6.4 权限验证检查 +- [x] 后端权限控制正确 (role 检查) +- [x] 前端画面访问控制正确 + +--- + +## 七、画面功能验证 + +### 7.1 画面清单 + +| 画面 | 文件路径 | 功能 | +|------|---------|------| +| AssessmentView | components/views/AssessmentView.tsx | 主评估界面 | +| AssessmentStatsView | components/views/AssessmentStatsView.tsx | 统计数据 | +| AssessmentTemplateManager | components/views/AssessmentTemplateManager.tsx | 模板管理 | +| QuestionBankView | components/views/QuestionBankView.tsx | 题库列表 | +| QuestionBankDetailView | components/views/QuestionBankDetailView.tsx | 题库详情 | + +### 7.2 按钮与功能验证 + +| 画面 | 按钮数量 | 输入框数量 | 功能闭环 | 状态 | +|------|---------|-----------|---------|------| +| AssessmentView | 14 | 1 | ✅ | ✅ | +| QuestionBankView | 10 | 3 | ✅ | ✅ | +| QuestionBankDetailView | 10 | 8 | ✅ | ✅ | +| AssessmentTemplateManager | 6 | 6 | ✅ | ✅ | +| AssessmentStatsView | 2 | 4 | ✅ | ✅ | + +### 7.3 输入框检查 + +| 画面 | 输入框 | onChange | onKeyDown | 验证 | 状态 | +|------|-------|----------|-----------|------|------| +| AssessmentView | 答案输入 | ✅ | Enter提交 | - | ✅ | +| QuestionBankView | 名称/描述/模板 | ✅ | - | 验证 | ✅ | +| QuestionBankDetailView | 题目属性 | ✅ | - | 转换 | ✅ | +| AssessmentTemplateManager | 模板字段 | ✅ | - | 解析 | ✅ | +| AssessmentStatsView | 日期/筛选 | ✅ | - | - | ✅ | + +### 7.4 参数传递链验证 + +``` +开始评估: startSession(selectedGroup, language, selectedTemplate) + ↓ +回答问题: submitAnswerStream(session.id, answer, language) + ↓ +时间检查: checkTimeLimits(session.id) [定期] + ↓ +获取证书: getCertificate(session.id) + ↓ +导出报告: exportExcel/Pdf(session.id) +``` + +--- + +## 八、调试技巧 + +### 8.1 日志添加 +- [x] 后端:console.log 在关键方法 +- [x] 前端:console.log 在 API 调用前后 +- [x] 日志包含关键变量值 + +**关键日志位置**: +- `assessment.service.ts`: startSession, submitAnswer, checkTimeLimits +- `assessment.controller.ts`: 所有方法入口 +- `AssessmentView.tsx`: handleStartAssessment, handleSubmitAnswer + +### 8.2 检查步骤 1. 查看 Docker logs:`docker compose logs server --tail 50` 2. 查看前端 Console(F12) 3. 查看 Network 面板请求响应 4. 直接调用 API 测试 -### 6.3 常见症状 -- [ ] 弹窗显示成功但数据未更新 → API 可能失败,检查返回数据格式 -- [ ] 页面空白无数据 → 检查 API 是否被调用,参数是否正确 -- [ ] 403 权限错误 → 检查用户角色是否匹配 +### 8.3 常见症状 +- [x] 弹窗显示成功但数据未更新 → API 可能失败,检查返回数据格式 +- [x] 页面空白无数据 → 检查 API 是否被调用,参数是否正确 +- [x] 403 权限错误 → 检查用户角色是否匹配 --- -## 七、重启前检查清单 +## 九、重启前检查清单 ### 代码层面 -- [ ] 所有修改的文件已保存 -- [ ] 没有语法错误 -- [ ] import 语句正确 +- [x] 所有修改的文件已保存 +- [x] 没有语法错误 +- [x] import 语句正确 +- [x] Entity @Column 类型完整 ### 构建层面 - [ ] `docker compose build` 成功 @@ -133,44 +345,160 @@ --- -## 八、典型问题模式 +## 十、典型问题模式 ### 问题1:新增功能不工作 **检查顺序:** -1. 后端 entity 是否注册到 app.module -2. 后端 service 是否在 module 中提供 -3. 后端 controller 是否有对应路由 -4. 前端 service 是否调用正确端点 -5. 前端 component 是否正确 import 和 export -6. 前端 route 是否添加 +1. [x] 后端 entity 是否注册到 app.module +2. [x] 后端 service 是否在 module 中提供 +3. [x] 后端 controller 是否有对应路由 +4. [x] 前端 service 是否调用正确端点 +5. [x] 前端 component 是否正确 import 和 export +6. [x] 前端 route 是否添加 ### 问题2:数据创建成功但查询不到 **检查顺序:** -1. tenantId 是否正确设置 -2. 查询条件是否匹配(== vs IS NULL) -3. 权限是否正确 +1. [x] tenantId 是否正确设置 +2. [x] 查询条件是否匹配(== vs IS NULL) +3. [x] 权限是否正确 ### 问题3:类型不匹配 **检查顺序:** -1. 后端 entity 类型 -2. 后端 DTO 类型 -3. 前端 service 接口类型 -4. 前端 types 定义 -5. API 响应格式 +1. [x] 后端 entity 类型 +2. [x] 后端 DTO 类型 +3. [x] 前端 service 接口类型 +4. [x] 前端 types 定义 +5. [x] API 响应格式 --- -## 九、相关文件位置 +## 十一、题库生成与关联功能深度检查 (2026-05-14) + +### 11.1 题库生成 (generateQuestions) + +**检查项**: +- [x] 输入验证 - count 范围检查 (1-50) +- [x] 输入验证 - knowledgeBaseContent 最小长度检查 +- [x] 错误处理 - JSON 解析失败处理 +- [x] 性能优化 - 批量保存而非逐个保存 + +**修复记录**: +- 添加 count 范围验证: `if (count <= 0 || count > 50) throw new Error(...)` +- 添加 content 最小长度验证: `if (!knowledgeBaseContent || content.trim().length < 10)` +- 修改为批量保存: `items.push(item)` → `await this.itemRepository.save(items)` + +### 11.2 题目选择 (selectQuestions) + +**检查项**: +- [x] 循环条件逻辑 - 正确终止条件 +- [x] 随机选择算法 - Fisher-Yates shuffle +- [x] 维度分布 - 均匀轮询 + +**修复记录**: +- 重写循环逻辑: `while (selected.length < count && availableItems.length > 0)` +- 添加 shuffle 方法: `private shuffleArray(array: T[]): T[]` +- 添加循环次数上限: `if (dimIdx >= DIMENSIONS.length * 3) break` + +### 11.3 题库状态管理 + +**检查项**: +- [x] submitForReview - DRAFT 状态检查 +- [x] review - PENDING_REVIEW 状态检查 +- [x] publish - PUBLISHED/REJECTED 状态检查 + +**状态**: ✅ 逻辑正确 + +### 11.4 评估启动与题目关联 + +**检查项**: +- [x] 题库查询逻辑 - PUBLISHED 状态过滤 +- [x] 题目数量检查 - 不足时回退到 LLM 生成 +- [x] 题目选择调用 - selectQuestions 方法 + +**状态**: ✅ 逻辑正确 + +### 11.5 LangGraph 题目生成节点 + +**检查项**: +- [x] 已有足够题目时跳过生成 +- [x] 已有题目传递到状态 + +**修复记录**: +- 添加跳过逻辑: +```typescript +if (existingQuestions.length >= limitCount) { + console.log('[GeneratorNode] Skipping generation - enough questions from bank'); + return { questions: existingQuestions }; +} +``` + +### 11.6 其他关联功能检查 + +| 功能模块 | 检查结果 | 备注 | +|---------|---------|------| +| 批量审核 (batchReviewItems) | ✅ | 状态更新、comment 追加 | +| 证书生成 (generateCertificate) | ✅ | 等级判定、已有证书复用 | +| 数据导出 (exportToExcel/Pdf) | ⚠️ | question 无 order 字段 | +| 时间控制 (checkTimeLimits) | ✅ | 计算逻辑正确 | +| 评估审核 (reviewAssessment) | ⚠️ | 审核后 passed 未更新 → 已修复 | +| 统计功能 (getStats/radar/trend) | ✅ | 权限过滤、数据聚合 | +| 权限控制 (isAdmin) | ✅ | 角色检查正确 | +| 错误处理 | ✅ | NotFound/Forbidden/Error | +| 空值处理 | ✅ | 默认值防护完善 | + +### 11.7 本次修复汇总 + +| # | 问题 | 位置 | 严重程度 | 状态 | +|---|------|------|---------|------| +| 1 | selectQuestions 循环条件错误 | question-bank.service.ts | 高 | ✅ | +| 2 | 随机选择算法不均匀 | question-bank.service.ts | 中 | ✅ | +| 3 | count 无上限检查 | question-bank.service.ts | 中 | ✅ | +| 4 | 空 content 仍调用 LLM | question-bank.service.ts | 中 | ✅ | +| 5 | 逐个保存而非批量 | question-bank.service.ts | 低 | ✅ | +| 6 | 已有足够题目时仍生成 | generator.node.ts | 高 | ✅ | +| 7 | 审核后 passed 未更新 | assessment.service.ts | 中 | ✅ | + +--- + +## 附录:相关文件位置 ### 后端核心 - `server/src/app.module.ts` - Entity 注册 - `server/src/assessment/assessment.module.ts` - 模块配置 - `server/src/assessment/entities/` - 数据实体 -- `server/src/assessment/services/question-bank.service.ts` - 业务逻辑 -- `server/src/assessment/controllers/question-bank.controller.ts` - API 路由 +- `server/src/assessment/assessment.service.ts` - 业务逻辑 +- `server/src/assessment/assessment.controller.ts` - API 路由 +- `server/src/assessment/graph/` - LangGraph 状态机 +- `server/src/assessment/graph/nodes/generator.node.ts` - 题目生成节点 +- `server/src/assessment/graph/nodes/interviewer.node.ts` - 题目展示节点 +- `server/src/assessment/graph/nodes/grader.node.ts` - 评分节点 +- `server/src/assessment/services/question-bank.service.ts` - 题库服务 +- `server/src/assessment/services/export.service.ts` - 导出服务 ### 前端核心 - `web/index.tsx` - 路由配置 -- `web/services/questionBankService.ts` - API 调用 -- `web/components/views/QuestionBankView.tsx` - 页面组件 -- `web/types.ts` - 类型定义 \ No newline at end of file +- `web/services/assessmentService.ts` - 评估API调用 +- `web/services/questionBankService.ts` - 题库API调用 +- `web/services/templateService.ts` - 模板API调用 +- `web/components/views/AssessmentView.tsx` - 评估页面 +- `web/components/views/AssessmentStatsView.tsx` - 统计页面 +- `web/components/views/QuestionBankView.tsx` - 题库列表 +- `web/components/views/QuestionBankDetailView.tsx` - 题库详情 +- `web/components/views/AssessmentTemplateManager.tsx` - 模板管理 +- `web/types.ts` - 类型定义 + +--- + +## 版本记录 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-03-17 | 初始版本 | +| 2.0 | 2026-05-14 | 全面更新,新增角色验证、画面验证、参数传递验证、Entity类型修复 | +| 2.1 | 2026-05-14 | 深度检查题库生成及关联功能,修复 7 个问题 | + +--- + +**检查完成时间**: 2026-05-14 +**检查结果**: ✅ 代码层面全部通过 +**待验证**: 运行时功能(需Docker环境) \ No newline at end of file diff --git a/docs/git-setup.md b/docs/git-setup.md new file mode 100644 index 0000000..1f363db --- /dev/null +++ b/docs/git-setup.md @@ -0,0 +1,58 @@ +# Git 配置指南 + +## Gitee 仓库信息 + +- **仓库地址**: https://gitee.com/hangshuo652/aurak.git +- **分支**: master +- **用户名**: hangshuo652 + +## 获取 Access Token + +1. 登录 Gitee: https://gitee.com +2. 点击头像 → 设置 → 安全设置 → 个人访问令牌 +3. 生成新令牌,勾选权限: + - `project` (项目) + - `repo` (仓库) + - `pull_request` (Pull Request) + - `push` (推送) +4. 复制生成的 token + +## 配置远程仓库 + +```bash +# 查看当前 remote +git remote -v + +# 更新 remote(替换为你的 token) +git remote set-url origin https://用户名:token@gitee.com/用户名/仓库名.git +``` + +## 常用 Git 命令 + +```bash +# 查看状态 +git status + +# 添加所有更改 +git add . + +# 提交更改 +git commit -m "描述" + +# 推送到远程 +git push + +# 拉取最新代码 +git pull +``` + +## 当前环境 + +- 工作目录: D:/AuraK +- Node.js + NestJS 后端 +- React + TypeScript 前端 +- Docker Compose 部署 + +--- + +**最后更新**: 2026-05-14 \ No newline at end of file diff --git a/docs/plans/2026-05-14-code-integration-review.md b/docs/plans/2026-05-14-code-integration-review.md new file mode 100644 index 0000000..b0ff15c --- /dev/null +++ b/docs/plans/2026-05-14-code-integration-review.md @@ -0,0 +1,366 @@ +# AuraK 人才评测系统代码整合性检查报告 + +> **文档状态**: ✅ 已完成修复 +> **创建日期**: 2026-05-14 +> **版本**: 1.1 +> **检查日期**: 2026-05-14 +> **修复日期**: 2026-05-14 + +--- + +## 一、检查背景 + +本次代码整合性检查从**各角色使用者**视角出发,对题目生成、测试、评估全流程进行系统性的审查。涵盖: + +- 用户故事与功能矩阵 +- API 路由与参数传递 +- 业务流与关系链 +- 特殊场景与边界情况 +- 前后端集成度 + +--- + +## 二、角色与用户故事 + +### 2.1 角色定义 + +| 角色 | 说明 | +|------|------| +| 普通用户 (User) | 被评估者,参与测评答题 | +| 管理员 (Admin) | 系统管理,全权限 | +| 审核员 (Reviewer) | 题目/题库审核 | +| 租户管理员 (Tenant Admin) | 租户内管理 | + +### 2.2 用户故事矩阵 + +#### 普通用户 + +| 编号 | 用户故事 | API 端点 | 状态 | +|------|---------|----------|------| +| US-01 | 开始评估 | POST /assessment/start | ✅ | +| US-02 | 回答问题 | POST /assessment/:id/answer | ✅ | +| US-03 | 追问回答 | POST /assessment/:id/answer | ✅ | +| US-04 | 查看历史(最新3条) | GET /assessment/history | ✅ | +| US-05 | 查看最终报告 | GET /assessment/:id/state | ✅ | +| US-06 | 下载证书 | GET /assessment/:id/certificate | ✅ | +| US-07 | 导出Excel报告 | GET /assessment/:id/export/excel | ✅ | +| US-08 | 导出PDF报告 | GET /assessment/:id/export/pdf | ✅ | +| US-09 | 时间检查 | GET /assessment/:id/time-check | ✅ | +| US-10 | 下一题计时 | POST /assessment/:id/next-question | ✅ | + +#### 管理员 + +| 编号 | 用户故事 | API 端点 | 状态 | +|------|---------|----------|------| +| AM-01 | 创建模板 | POST /assessment/templates | ✅ | +| AM-02 | 编辑模板 | PUT /assessment/templates/:id | ✅ | +| AM-03 | 删除模板 | DELETE /assessment/templates/:id | ✅ | +| AM-04 | 创建题库 | POST /question-banks | ✅ | +| AM-05 | 编辑题库 | PUT /question-banks/:id | ✅ | +| AM-06 | 删除题库 | DELETE /question-banks/:id | ✅ | +| AM-07 | AI生成题目 | POST /question-banks/:bankId/generate | ✅ | +| AM-08 | 添加题目 | POST /question-banks/:bankId/items | ✅ | +| AM-09 | 编辑题目 | PUT /question-banks/:bankId/items/:id | ✅ | +| AM-10 | 删除题目 | DELETE /question-banks/:bankId/items/:id | ✅ | +| AM-11 | 提交审核 | PUT /question-banks/:id/submit | ✅ | +| AM-12 | 审核题库 | PUT /question-banks/:id/review | ✅ | +| AM-13 | 发布题库 | PUT /question-banks/:id/publish | ✅ | +| AM-14 | 批量审核题目 | POST /question-banks/:bankId/items/batch-review | ✅ | +| AM-15 | 审核评估 | PUT /assessment/:id/review | ✅ | +| AM-16 | 删除评估 | DELETE /assessment/:id | ✅ | +| AM-17 | 查看统计 | GET /assessment/stats | ✅ | +| AM-18 | 查看雷达图 | GET /assessment/stats/radar | ✅ | +| AM-19 | 查看趋势图 | GET /assessment/stats/trend | ✅ | +| AM-20 | 验证证书 | GET /assessment/certificate/verify/:id | ✅ | +| AM-21 | 公开证书信息 | GET /assessment/certificate/public/:id | ✅ | + +#### 审核员 + +| 编号 | 用户故事 | API 端点 | 状态 | +|------|---------|----------|------| +| RV-01 | 审核题库 | PUT /question-banks/:id/review | ✅ | +| RV-02 | 批量审核题目 | POST /question-banks/:bankId/items/batch-review | ✅ | + +--- + +## 三、发现的问题清单 + +### 3.1 高优先级问题 + +| 编号 | 问题描述 | 位置 | 类型 | 修复建议 | +|------|---------|------|------|---------| +| P1-01 | 两个 `Get('history')` 路由冲突,会导致第一个被覆盖 | assessment.controller.ts:109, :159 | 路由冲突 | 删除或重命名其中一个 | +| P1-02 | 前端缺少多个API调用方法 | web/services/assessmentService.ts | 前端集成 | 添加缺失的API方法 | +| P1-03 | 前端缺少题库/模板服务 | web/services/ | 前端集成 | 新增 QuestionBankService, TemplateService | + +### 3.2 中优先级问题 + +| 编号 | 问题描述 | 位置 | 类型 | 修复建议 | +|------|---------|------|------|---------| +| P2-01 | QuestionBank.status 字段无默认值 | question-bank.entity.ts:53 | Entity配置 | 添加 default: QuestionBankStatus.DRAFT | +| P2-02 | 状态前置检查缺失 | question-bank.service.ts | 业务逻辑 | 审核/发布/提交前检查当前状态 | +| P2-03 | 缺少强制结束评估功能 | assessment.controller.ts | 功能缺失 | 添加 POST /assessment/:id/force-end | + +### 3.3 低优先级问题 + +| 编号 | 问题描述 | 位置 | 类型 | 修复建议 | +|------|---------|------|------|---------| +| P3-01 | `@Put` 未导入 | assessment.controller.ts:224 | 导入缺失 | 在 import 中添加 Put | +| P3-02 | 缺少操作审计日志 | 整体设计 | 架构设计 | 可选:添加审计日志表 | +| P3-03 | 批量操作功能缺失 | assessment.service.ts | 功能缺失 | 可选:批量删除/导出 | +| P3-04 | 无事务/并发控制 | 整体设计 | 架构设计 | 可选:后续优化 | + +--- + +## 四、特殊场景检查 + +### 4.1 考核过程特殊场景 + +| 场景 | 状态 | 说明 | +|------|------|------| +| 时间超时处理 | ✅ | checkTimeLimits, isTotalTimeout/isQuestionTimeout | +| 中断恢复 | ✅ | interruptAfter + MemorySaver | +| 追问场景 | ✅ | shouldFollowUp 逻辑 | +| 网络中断恢复 | ⚠️ | 依赖LangGraph MemorySaver,无重连机制 | +| 并发答题 | ⚠️ | 无分布式锁,可能冲突 | +| 中途放弃 | ✅ | 用户可删除自己的session | + +### 4.2 管理员特殊场景 + +| 场景 | 状态 | 说明 | +|------|------|------| +| 强制结束评估 | ❌ | 无此功能 | +| 批量删除评估 | ❌ | 无批量删除API | +| 批量导出数据 | ❌ | 无批量导出 | +| 强制重置分数 | ⚠️ | 需通过 review 接口手动调整 | + +### 4.3 业务规则验证 + +| 规则 | 状态 | 说明 | +|------|------|------| +| 只能审核已提交的题库 | ⚠️ | 无状态前置检查 | +| 只能发布已审核通过的 | ⚠️ | 无状态前置检查 | +| 只能在评估进行中回答 | ⚠️ | 无状态前置检查 | +| 只能在评估完成后生成证书 | ✅ | 已检查 | +| 只能删除自己的评估(非admin) | ✅ | 已实现 | + +--- + +## 五、API 路由检查 + +### 5.1 后端路由清单 + +| 端点 | 方法 | 功能 | 状态 | +|------|------|------|------| +| /assessment/start | POST | 开始评估 | ✅ | +| /assessment/:id/answer | POST | 回答问题 | ✅ | +| /assessment/:id/state | GET | 获取状态 | ✅ | +| /assessment/:id/certificate | GET | 获取证书 | ✅ | +| /assessment/:id/review | PUT | 审核评估 | ✅ | +| /assessment/:id/time-check | GET | 时间检查 | ✅ | +| /assessment/:id/next-question | POST | 下一题计时 | ✅ | +| /assessment/:id/export/excel | GET | 导出Excel | ✅ | +| /assessment/:id/export/pdf | GET | 导出PDF | ✅ | +| /assessment/stats | GET | 统计数据 | ✅ | +| /assessment/stats/radar | GET | 雷达图数据 | ✅ | +| /assessment/stats/trend | GET | 趋势图数据 | ✅ | +| /assessment/history | GET | 用户历史 | ⚠️ 冲突 | +| /assessment/certificate/verify/:id | GET | 验证证书 | ✅ | +| /assessment/certificate/public/:id | GET | 公开证书 | ✅ | +| /assessment/templates | POST/GET | 模板CRUD | ✅ | +| /question-banks | POST/GET | 题库CRUD | ✅ | +| /question-banks/:id/submit | PUT | 提交审核 | ✅ | +| /question-banks/:id/review | PUT | 审核题库 | ✅ | +| /question-banks/:id/publish | PUT | 发布题库 | ✅ | +| /question-banks/:bankId/items | CRUD | 题目CRUD | ✅ | +| /question-banks/:bankId/generate | POST | AI生成题目 | ✅ | +| /question-banks/:bankId/items/batch-review | POST | 批量审核 | ✅ | + +--- + +## 六、数据关系链 + +### 6.1 Entity 关系图 + +``` +AssessmentTemplate (1) ←→ (N) AssessmentSession + ↑ + │ + ↓ +QuestionBank (1) ←→ (N) QuestionBankItem + │ + ↓ +AssessmentSession (1) ←→ (N) AssessmentQuestion + │ + ↓ +AssessmentQuestion (1) ←→ (N) AssessmentAnswer + │ + ↓ +AssessmentSession (1) ←→ (1) AssessmentCertificate +``` + +### 6.2 字段继承关系 + +| 源 | 目标 | 字段 | +|------|------|------| +| AssessmentTemplate | AssessmentSession | name, keywords, questionCount, difficultyConfig, weightConfig, passingScore, totalTimeLimit, perQuestionTimeLimit | +| TemplateService | startSession | templateId → templateId | +| question-bank.service | startSession | bankId → questions | + +--- + +## 七、前后端集成检查 + +### 7.1 前端缺失的 API 方法 + +| 后端API | 前端需要的方法 | +|---------|---------------| +| GET /assessment/:id/certificate | getCertificate() | +| PUT /assessment/:id/review | reviewAssessment() | +| GET /assessment/stats | getStats() | +| GET /assessment/stats/radar | getRadarStats() | +| GET /assessment/stats/trend | getTrendStats() | +| GET /assessment/:id/export/excel | exportExcel() | +| GET /assessment/:id/export/pdf | exportPdf() | +| GET /assessment/:id/time-check | checkTimeLimits() | +| POST /assessment/:id/next-question | startNextQuestion() | +| POST /question-banks/* | questionBankService (缺失) | +| POST /assessment/templates/* | templateService (缺失) | + +--- + +## 八、修复优先级与计划 + +### 立即修复 (P0) + +1. **P1-01**: 修复路由冲突 - 删除或重命名第109行的 Get('history') +2. **P1-02**: 补全前端 assessmentService.ts 缺失的 API 方法 +3. **P1-03**: 新增前端题库/模板服务 + +### 短期修复 (P1) + +4. **P2-01**: QuestionBank.status 添加默认值 +5. **P2-02**: 添加状态前置检查逻辑 +6. **P2-03**: 添加强制结束评估功能 + +### 后续优化 (P2) + +7. **P3-01**: 修复 Put 导入 +8. **P3-02**: 审计日志 (可选) +9. **P3-03**: 批量操作 (可选) +10. **P3-04**: 事务控制 (可选) + +--- + +## 九、版本信息 + +| 版本 | 日期 | 说明 | +|------|------|------| +| 1.0 | 2026-05-14 | 初始版本,涵盖代码整合性检查 | +| 1.1 | 2026-05-14 | 修复路由冲突、前后端集成等问题 | + +--- + +## 十、修复记录 + +### 2026-05-14 修复内容 + +| ID | 问题 | 修复文件 | 修复内容 | +|----|------|---------|---------| +| R1 | 路由冲突 | assessment.controller.ts | 删除第109行 Get() 方法 | +| R2 | Put 导入缺失 | assessment.controller.ts | 添加 Put 到 import 解构 | +| R3 | Req 导入缺失 | assessment.controller.ts | 添加 Req 到 import 解构 | +| R4 | ForbiddenException 缺失 | assessment.controller.ts | 添加 ForbiddenException 导入 | +| S1 | publish 状态检查缺失 | question-bank.service.ts | 添加 PUBLISHED/REJECTED 状态检查 | +| M1 | QuestionBank status 默认值 | question-bank.entity.ts | 添加 default: DRAFT | +| M2 | 强制结束评估功能 | assessment.service.ts | 添加 forceEndAssessment 方法 | +| M3 | 强制结束端点 | assessment.controller.ts | 添加 POST :id/force-end | +| F1 | 前端 API 方法缺失 | web/services/assessmentService.ts | 添加所有缺失的 API 方法 | + +--- + +## 十一、修复后验证 + +### 11.1 路由清单 (修复后) + +``` +POST /assessment/start - 开始评估 +POST /assessment/:id/answer - 回答问题 +GET /assessment/:id/state - 获取状态 +DELETE /assessment/:id - 删除评估 +GET /assessment/:id/certificate - 获取证书 +GET /assessment/certificate/verify/:certificateId - 验证证书 +GET /assessment/certificate/public/:sessionId - 公开证书 +GET /assessment/history - 用户历史 (最新3条) +GET /assessment/stats - 统计数据 +GET /assessment/stats/radar - 雷达图数据 +GET /assessment/stats/trend - 趋势图数据 +PUT /assessment/:id/review - 审核评估 +GET /assessment/:id/time-check - 时间检查 +POST /assessment/:id/next-question - 下一题计时 +POST /assessment/:id/force-end - 强制结束 (admin) +GET /assessment/:id/export/excel - 导出Excel +GET /assessment/:id/export/pdf - 导出PDF +``` + +### 11.2 参数传递链验证 + +| 功能 | Controller 参数 | Service 参数 | 状态 | +|------|----------------|-------------|------| +| startSession | knowledgeBaseId?, language?, templateId? | userId, kbId, tenantId, language, templateId | ✅ | +| submitAnswer | answer, language | sessionId, userId, answer, language | ✅ | +| review | newScore, comment | sessionId, newScore, comment, reviewerId, tenantId | ✅ | +| forceEnd | sessionId | sessionId | ✅ | + +### 11.3 状态检查验证 + +| 操作 | 前置检查 | 状态 | +|------|---------|------| +| submitForReview | DRAFT | ✅ | +| review | PENDING_REVIEW | ✅ | +| publish | PUBLISHED/REJECTED | ✅ 已修复 | +| reviewAssessment | COMPLETED | ✅ | +| forceEnd | 无限制 | ✅ | + +--- + +## 十二、前端画面验证修复 + +### 12.1 AssessmentView (主评估界面) 修复 + +| 功能 | 修复前 | 修复后 | 状态 | +|------|--------|--------|------| +| 查看证书 | 未实现 | 添加 getCertificate 调用 | ✅ | +| 导出PDF报告 | 按钮无功能 | 添加 exportPdf 调用 | ✅ | +| 导出Excel报告 | 未实现 | 添加 exportExcel 调用 | ✅ | +| 时间显示 | 未实现 | 添加 checkTimeLimits 定期调用 | ✅ | + +### 12.2 画面功能验证 + +| 画面 | 用户故事覆盖 | API完整性 | 参数传递 | 闭环 | +|------|-------------|-----------|---------|------| +| AssessmentView | 10/10 | ✅ | ✅ | ✅ | +| AssessmentStatsView | 7/7 | ✅ | ✅ | ✅ | +| AssessmentTemplateManager | 5/5 | ✅ | ✅ | ✅ | +| QuestionBankView | 6/6 | ✅ | ✅ | ✅ | +| QuestionBankDetailView | 7/7 | ✅ | ✅ | ✅ | + +--- + +## 十三、附录 + +### A. 相关文件路径 + +- Controller: `server/src/assessment/assessment.controller.ts` +- Service: `server/src/assessment/assessment.service.ts` +- Entities: `server/src/assessment/entities/` +- Graph: `server/src/assessment/graph/` +- 前端服务: `web/services/assessmentService.ts` +- 前端题库服务: `web/services/questionBankService.ts` +- 前端模板服务: `web/services/templateService.ts` + +### B. 参考文档 + +- `docs/plans/2026-04-23-assessment-system-full-plan-v2.md` +- `docs/debugging-checklist.md` +- `docs/admin-credentials.md` \ No newline at end of file diff --git a/server/src/assessment/assessment.controller.ts b/server/src/assessment/assessment.controller.ts index f9454f3..a2801d8 100644 --- a/server/src/assessment/assessment.controller.ts +++ b/server/src/assessment/assessment.controller.ts @@ -6,13 +6,17 @@ import { Param, UseGuards, Request, + Req, Sse, MessageEvent, Query, Delete, + Put, + ForbiddenException, } from '@nestjs/common'; import { map } from 'rxjs/operators'; import { AssessmentService } from './assessment.service'; +import { ExportService } from './services/export.service'; import { CombinedAuthGuard } from '../auth/combined-auth.guard'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @@ -20,7 +24,10 @@ import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; @Controller('assessment') @UseGuards(CombinedAuthGuard) export class AssessmentController { - constructor(private readonly assessmentService: AssessmentService) {} + constructor( + private readonly assessmentService: AssessmentService, + private readonly exportService: ExportService, + ) {} @Post('start') @ApiOperation({ summary: 'Start a new assessment session' }) @@ -102,16 +109,6 @@ export class AssessmentController { return this.assessmentService.getSessionState(sessionId, userId); } - @Get() - @ApiOperation({ summary: 'Get assessment session history' }) - async getHistory(@Request() req: any) { - const { id: userId, tenantId } = req.user; - console.log( - `[AssessmentController] getHistory: user=${userId}, tenant=${tenantId}`, - ); - return this.assessmentService.getHistory(userId, tenantId); - } - @Delete(':id') @ApiOperation({ summary: 'Delete an assessment session' }) async deleteSession(@Request() req: any, @Param('id') sessionId: string) { @@ -135,6 +132,23 @@ export class AssessmentController { return this.assessmentService.generateCertificate(sessionId, userId, tenantId); } + @Get('certificate/verify/:certificateId') + @ApiOperation({ summary: 'Verify certificate by ID (public)' }) + @UseGuards() + async verifyCertificate( + @Param('certificateId') certificateId: string, + ) { + return this.assessmentService.verifyCertificate(certificateId); + } + + @Get('certificate/public/:sessionId') + @ApiOperation({ summary: 'Get public certificate info for verification' }) + async getPublicCertificate( + @Param('sessionId') sessionId: string, + ) { + return this.assessmentService.getPublicCertificateInfo(sessionId); + } + @Get('history') @ApiOperation({ summary: 'Get current user assessment history (keep latest 3)' }) async getHistory( @@ -168,6 +182,38 @@ export class AssessmentController { ); } + @Get('stats/radar') + @ApiOperation({ summary: 'Get radar chart data for dimension scores' }) + async getRadarStats( + @Request() req: any, + @Query('templateId') templateId?: string, + ) { + const { id: userId, tenantId, role } = req.user; + return this.assessmentService.getRadarStats( + userId, + tenantId, + role, + templateId, + ); + } + + @Get('stats/trend') + @ApiOperation({ summary: 'Get trend data for scores over time' }) + async getTrendStats( + @Request() req: any, + @Query('startDate') startDate?: string, + @Query('endDate') endDate?: string, + ) { + const { id: userId, tenantId, role } = req.user; + return this.assessmentService.getTrendStats( + userId, + tenantId, + role, + startDate, + endDate, + ); + } + @Put(':id/review') @ApiOperation({ summary: 'Review assessment - adjust final score' }) async review( @@ -184,4 +230,51 @@ export class AssessmentController { tenantId, ); } + + @Get(':id/time-check') + @ApiOperation({ summary: 'Check assessment time limits' }) + async checkTimeLimits(@Param('id') sessionId: string) { + return this.assessmentService.checkTimeLimits(sessionId); + } + + @Post(':id/next-question') + @ApiOperation({ summary: 'Start timing for next question' }) + async nextQuestion(@Param('id') sessionId: string) { + await this.assessmentService.updateQuestionStartTime(sessionId); + return { success: true }; + } + + @Post(':id/force-end') + @ApiOperation({ summary: 'Force end assessment (admin only)' }) + async forceEnd( + @Param('id') sessionId: string, + @Request() req: any, + ) { + const { role } = req.user; + const isAdmin = role === 'super_admin' || role === 'admin'; + if (!isAdmin) { + throw new ForbiddenException('Only admin can force end assessment'); + } + return this.assessmentService.forceEndAssessment(sessionId); + } + + @Get(':id/export/excel') + @ApiOperation({ summary: 'Export assessment to Excel' }) + async exportExcel(@Param('id') sessionId: string) { + const buffer = await this.exportService.exportToExcel(sessionId); + return { + filename: `assessment-${sessionId}.xlsx`, + buffer: buffer.toString('base64'), + }; + } + + @Get(':id/export/pdf') + @ApiOperation({ summary: 'Export assessment to PDF (text format)' }) + async exportPdf(@Param('id') sessionId: string) { + const buffer = await this.exportService.exportToPdf(sessionId); + return { + filename: `assessment-${sessionId}.txt`, + content: buffer.toString('utf-8'), + }; + } } diff --git a/server/src/assessment/assessment.module.ts b/server/src/assessment/assessment.module.ts index 56941db..15389cf 100644 --- a/server/src/assessment/assessment.module.ts +++ b/server/src/assessment/assessment.module.ts @@ -22,6 +22,7 @@ import { QuestionBankController } from './controllers/question-bank.controller'; import { ContentFilterService } from './services/content-filter.service'; import { QuestionOutlineService } from './services/question-outline.service'; import { QuestionBankService } from './services/question-bank.service'; +import { ExportService } from './services/export.service'; @Module({ imports: [ @@ -49,7 +50,8 @@ import { QuestionBankService } from './services/question-bank.service'; ContentFilterService, QuestionOutlineService, QuestionBankService, + ExportService, ], - exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService], + exports: [AssessmentService, TemplateService, QuestionOutlineService, QuestionBankService, ExportService], }) export class AssessmentModule {} diff --git a/server/src/assessment/assessment.service.ts b/server/src/assessment/assessment.service.ts index bcfc6f5..45ec985 100644 --- a/server/src/assessment/assessment.service.ts +++ b/server/src/assessment/assessment.service.ts @@ -526,6 +526,10 @@ private async getModel(tenantId: string): Promise { language, questions_json: questionsFromBank.length > 0 ? questionsFromBank : [], questionSource, + startedAt: new Date(), + currentQuestionStartedAt: new Date(), + totalTimeLimit: template?.totalTimeLimit || 1800, + perQuestionTimeLimit: template?.perQuestionTimeLimit || 300, }; const content = await this.getSessionContent(sessionData); @@ -1442,6 +1446,82 @@ const initialState: Partial = { }; } + async getRadarStats(userId: string, tenantId: string, role: string, templateId?: string): Promise { + const isAdmin = role === 'super_admin' || role === 'admin'; + + const qb = this.sessionRepository.createQueryBuilder('session'); + qb.where('session.tenantId = :tenantId', { tenantId }); + qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED }); + + if (!isAdmin) { + qb.andWhere('session.userId = :userId', { userId }); + } + if (templateId) { + qb.andWhere('session.templateId = :templateId', { templateId }); + } + + const sessions = await qb.take(100).getMany(); + + const dimensionScores: Record = { + PROMPT: [], + LLM: [], + IDE: [], + DEV_PATTERN: [], + WORK_CAPABILITY: [], + }; + + for (const session of sessions) { + const messages = session.messages || []; + for (const msg of messages) { + if (msg.dimension && msg.score !== undefined) { + dimensionScores[msg.dimension]?.push(msg.score); + } + } + } + + const radarData: Record = {}; + for (const [dim, scores] of Object.entries(dimensionScores)) { + if (scores.length > 0) { + radarData[dim] = Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 10) / 10; + } else { + radarData[dim] = 0; + } + } + + return { radarData, sampleCount: sessions.length }; + } + + async getTrendStats(userId: string, tenantId: string, role: string, startDate?: string, endDate?: string): Promise { + const isAdmin = role === 'super_admin' || role === 'admin'; + + const qb = this.sessionRepository.createQueryBuilder('session'); + qb.where('session.tenantId = :tenantId', { tenantId }); + qb.andWhere('session.status = :status', { status: AssessmentStatus.COMPLETED }); + + if (!isAdmin) { + qb.andWhere('session.userId = :userId', { userId }); + } + if (startDate) { + qb.andWhere('session.createdAt >= :startDate', { startDate: new Date(startDate) }); + } + if (endDate) { + qb.andWhere('session.createdAt <= :endDate', { endDate: new Date(endDate) }); + } + + const sessions = await qb + .orderBy('session.createdAt', 'ASC') + .take(50) + .getMany(); + + const trendData = sessions.map(session => ({ + date: session.createdAt, + score: session.finalScore || 0, + template: session.template?.name || '-', + })); + + return { trendData, count: sessions.length }; + } + async reviewAssessment( sessionId: string, newScore: number, @@ -1477,6 +1557,8 @@ const initialState: Partial = { } session.finalScore = newScore; + const passingScore = session.templateJson?.passingScore || 90; + (session as any).passed = newScore >= passingScore; session.reviewedBy = reviewerId; session.reviewedAt = new Date(); session.reviewComment = comment || null; @@ -1515,4 +1597,140 @@ const initialState: Partial = { this.logger.log(`[cleanupOldSessions] Deleted ${toDelete.length} old sessions for user ${userId}`); } } + + async checkTimeLimits(sessionId: string): Promise<{ + totalTimeRemaining: number; + questionTimeRemaining: number; + isTotalTimeout: boolean; + isQuestionTimeout: boolean; + }> { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session || session.status === AssessmentStatus.COMPLETED) { + return { + totalTimeRemaining: 0, + questionTimeRemaining: 0, + isTotalTimeout: true, + isQuestionTimeout: true, + }; + } + + const now = new Date(); + const startTime = session.startedAt ? new Date(session.startedAt) : now; + const questionStartTime = session.currentQuestionStartedAt ? new Date(session.currentQuestionStartedAt) : now; + + const totalElapsed = Math.floor((now.getTime() - startTime.getTime()) / 1000); + const questionElapsed = Math.floor((now.getTime() - questionStartTime.getTime()) / 1000); + + const totalTimeRemaining = Math.max(0, session.totalTimeLimit - totalElapsed); + const questionTimeRemaining = Math.max(0, session.perQuestionTimeLimit - questionElapsed); + + return { + totalTimeRemaining, + questionTimeRemaining, + isTotalTimeout: totalElapsed >= session.totalTimeLimit, + isQuestionTimeout: questionElapsed >= session.perQuestionTimeLimit, + }; + } + + async updateQuestionStartTime(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + if (session) { + session.currentQuestionStartedAt = new Date(); + await this.sessionRepository.save(session); + } + } + + async verifyCertificate(certificateId: string): Promise<{ + valid: boolean; + certificate?: { + id: string; + level: string; + totalScore: number; + passed: boolean; + issuedAt: Date; + userId: string; + }; + message?: string; + }> { + const certificate = await this.certificateRepository.findOne({ + where: { id: certificateId }, + relations: ['user'], + }); + + if (!certificate) { + return { valid: false, message: 'Certificate not found' }; + } + + return { + valid: true, + certificate: { + id: certificate.id, + level: certificate.level, + totalScore: certificate.totalScore, + passed: certificate.passed, + issuedAt: certificate.issuedAt, + userId: certificate.userId, + }, + }; + } + + async getPublicCertificateInfo(sessionId: string): Promise<{ + exists: boolean; + certificate?: { + level: string; + totalScore: number; + passed: boolean; + issuedAt: Date; + dimensionScores: Record; + }; + message?: string; + }> { + const certificate = await this.certificateRepository.findOne({ + where: { sessionId }, + }); + + if (!certificate) { + return { exists: false, message: 'Certificate not found for this session' }; + } + + return { + exists: true, + certificate: { + level: certificate.level, + totalScore: certificate.totalScore, + passed: certificate.passed, + issuedAt: certificate.issuedAt, + dimensionScores: certificate.dimensionScores || {}, + }, + }; + } + + async forceEndAssessment(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + }); + + if (!session) { + throw new NotFoundException('Assessment session not found'); + } + + if (session.status === AssessmentStatus.COMPLETED) { + return session; + } + + session.status = AssessmentStatus.COMPLETED; + session.finalReport = '评估已被管理员强制结束'; + session.finalScore = 0; + + await this.sessionRepository.save(session); + + this.logger.log(`[forceEndAssessment] Session ${sessionId} force ended by admin`); + + return session; + } } diff --git a/server/src/assessment/entities/assessment-answer.entity.ts b/server/src/assessment/entities/assessment-answer.entity.ts index cb3fcf0..492eb02 100644 --- a/server/src/assessment/entities/assessment-answer.entity.ts +++ b/server/src/assessment/entities/assessment-answer.entity.ts @@ -14,7 +14,7 @@ export class AssessmentAnswer { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'question_id' }) + @Column({ name: 'question_id', type: 'text' }) questionId: string; @ManyToOne( diff --git a/server/src/assessment/entities/assessment-certificate.entity.ts b/server/src/assessment/entities/assessment-certificate.entity.ts index f86cc1b..f887bf5 100644 --- a/server/src/assessment/entities/assessment-certificate.entity.ts +++ b/server/src/assessment/entities/assessment-certificate.entity.ts @@ -13,26 +13,26 @@ export class AssessmentCertificate { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'user_id' }) + @Column({ name: 'user_id', type: 'text' }) userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; - @Column({ name: 'session_id' }) + @Column({ name: 'session_id', type: 'text' }) sessionId: string; - @Column({ name: 'template_id' }) + @Column({ name: 'template_id', type: 'text' }) templateId: string; - @Column() + @Column({ type: 'text' }) level: string; @Column({ type: 'float', name: 'total_score' }) totalScore: number; - @Column({ name: 'qr_code', nullable: true }) + @Column({ name: 'qr_code', nullable: true, type: 'text' }) qrCode: string; @Column({ name: 'dimension_scores', type: 'simple-json', nullable: true }) diff --git a/server/src/assessment/entities/assessment-question.entity.ts b/server/src/assessment/entities/assessment-question.entity.ts index d846f6e..63bedf5 100644 --- a/server/src/assessment/entities/assessment-question.entity.ts +++ b/server/src/assessment/entities/assessment-question.entity.ts @@ -16,7 +16,7 @@ export class AssessmentQuestion { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'session_id' }) + @Column({ name: 'session_id', type: 'text' }) sessionId: string; @ManyToOne( diff --git a/server/src/assessment/entities/assessment-session.entity.ts b/server/src/assessment/entities/assessment-session.entity.ts index 3420481..a08c65e 100644 --- a/server/src/assessment/entities/assessment-session.entity.ts +++ b/server/src/assessment/entities/assessment-session.entity.ts @@ -24,31 +24,31 @@ export class AssessmentSession { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ name: 'user_id' }) + @Column({ name: 'user_id', type: 'text' }) userId: string; @ManyToOne(() => User) @JoinColumn({ name: 'user_id' }) user: User; - @Column({ name: 'tenant_id', nullable: true }) + @Column({ name: 'tenant_id', nullable: true, type: 'text' }) tenantId: string; - @Column({ name: 'knowledge_base_id', nullable: true }) + @Column({ name: 'knowledge_base_id', nullable: true, type: 'text' }) knowledgeBaseId: string | null; @ManyToOne(() => KnowledgeBase, { nullable: true }) @JoinColumn({ name: 'knowledge_base_id' }) knowledgeBase: KnowledgeBase; - @Column({ name: 'knowledge_group_id', nullable: true }) + @Column({ name: 'knowledge_group_id', nullable: true, type: 'text' }) knowledgeGroupId: string | null; @ManyToOne(() => KnowledgeGroup, { nullable: true }) @JoinColumn({ name: 'knowledge_group_id' }) knowledgeGroup: KnowledgeGroup; - @Column({ name: 'thread_id', nullable: true }) + @Column({ name: 'thread_id', nullable: true, type: 'text' }) threadId: string; @Column({ @@ -85,6 +85,18 @@ export class AssessmentSession { @Column({ type: 'simple-json', name: 'review_history', nullable: true }) reviewHistory: any[]; + @Column({ name: 'started_at', nullable: true, type: 'datetime' }) + startedAt: Date | null; + + @Column({ name: 'current_question_started_at', nullable: true, type: 'datetime' }) + currentQuestionStartedAt: Date | null; + + @Column({ type: 'int', name: 'total_time_limit', default: 1800 }) + totalTimeLimit: number; + + @Column({ type: 'int', name: 'per_question_time_limit', default: 300 }) + perQuestionTimeLimit: number; + @Column({ type: 'int', name: 'current_question_index', default: 0 }) currentQuestionIndex: number; @@ -97,7 +109,7 @@ export class AssessmentSession { @Column({ type: 'varchar', length: 10, default: 'zh' }) language: string; - @Column({ name: 'template_id', nullable: true }) + @Column({ name: 'template_id', nullable: true, type: 'text' }) templateId: string; @ManyToOne(() => AssessmentTemplate, { nullable: true }) diff --git a/server/src/assessment/entities/assessment-template.entity.ts b/server/src/assessment/entities/assessment-template.entity.ts index f63ca65..f8ec84e 100644 --- a/server/src/assessment/entities/assessment-template.entity.ts +++ b/server/src/assessment/entities/assessment-template.entity.ts @@ -97,6 +97,12 @@ export class AssessmentTemplate { @Column({ type: 'int', name: 'passing_score', default: 90 }) passingScore: number; + @Column({ type: 'int', name: 'total_time_limit', default: 1800 }) + totalTimeLimit: number; + + @Column({ type: 'int', name: 'per_question_time_limit', default: 300 }) + perQuestionTimeLimit: number; + @CreateDateColumn({ name: 'created_at' }) createdAt: Date; diff --git a/server/src/assessment/graph/builder.ts b/server/src/assessment/graph/builder.ts index 17d089a..06fefca 100644 --- a/server/src/assessment/graph/builder.ts +++ b/server/src/assessment/graph/builder.ts @@ -11,9 +11,10 @@ import { reportAnalyzerNode } from './nodes/analyzer.node'; const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => { const targetCount = state.questionCount || 5; const questionsLen = state.questions?.length || 0; + const currentIndex = Math.max(0, state.currentQuestionIndex || 0); console.log('[Router] Evaluation Result:', { - currentIndex: state.currentQuestionIndex, + currentIndex, shouldFollowUp: state.shouldFollowUp, numQuestions: questionsLen, targetCount, @@ -24,9 +25,9 @@ const routeAfterGrading = (state: typeof EvaluationAnnotation.State) => { return 'interviewer'; } - if (state.currentQuestionIndex < targetCount) { + if (currentIndex < targetCount) { // If the next question isn't generated yet, go back to generator - if (state.currentQuestionIndex >= questionsLen) { + if (currentIndex >= questionsLen) { console.log('[Router] Index >= Questions, routing to generator'); return 'generator'; } diff --git a/server/src/assessment/graph/nodes/generator.node.ts b/server/src/assessment/graph/nodes/generator.node.ts index cb8338a..d5348c9 100644 --- a/server/src/assessment/graph/nodes/generator.node.ts +++ b/server/src/assessment/graph/nodes/generator.node.ts @@ -79,6 +79,12 @@ export const questionGeneratorNode = async ( .join('\n'); const existingQuestions = state.questions || []; + + if (existingQuestions.length >= limitCount) { + console.log('[GeneratorNode] Skipping generation - enough questions from bank:', existingQuestions.length); + return { questions: existingQuestions }; + } + const existingQuestionsText = existingQuestions .map((q, i) => `Q${i + 1}: ${q.questionText}`) .join('\n'); diff --git a/server/src/assessment/services/export.service.ts b/server/src/assessment/services/export.service.ts new file mode 100644 index 0000000..2501830 --- /dev/null +++ b/server/src/assessment/services/export.service.ts @@ -0,0 +1,227 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In } from 'typeorm'; +import * as XLSX from 'xlsx'; +import { AssessmentSession } from '../entities/assessment-session.entity'; +import { AssessmentQuestion } from '../entities/assessment-question.entity'; +import { AssessmentAnswer } from '../entities/assessment-answer.entity'; +import { AssessmentCertificate } from '../entities/assessment-certificate.entity'; + +@Injectable() +export class ExportService { + private readonly logger = new Logger(ExportService.name); + + constructor( + @InjectRepository(AssessmentSession) + private sessionRepository: Repository, + @InjectRepository(AssessmentQuestion) + private questionRepository: Repository, + @InjectRepository(AssessmentAnswer) + private answerRepository: Repository, + @InjectRepository(AssessmentCertificate) + private certificateRepository: Repository, + ) {} + + async exportToExcel(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + relations: ['template', 'user'], + }); + + if (!session) { + throw new Error('Session not found'); + } + + const questions = await this.questionRepository.find({ + where: { sessionId }, + order: { order: 'ASC' }, + }); + + const answers = await this.answerRepository.find({ + where: { questionId: In(questions.map((q) => q.id)) }, + }); + + const answerMap = new Map(answers.map((a) => [a.questionId, a])); + + const summarySheet = [ + ['评估报告'], + ['评估ID', session.id], + ['用户', session.user?.name || session.userId], + ['状态', session.status], + ['最终分数', session.finalScore || '-'], + ['原始分数', session.originalScore || '-'], + ['评估模板', session.template?.name || session.templateJson?.name || '-'], + ['开始时间', session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'], + ['完成时间', session.updatedAt ? new Date(session.updatedAt).toLocaleString() : '-'], + ['总用时(秒)', session.totalTimeLimit], + [''], + ['维度分数'], + ...this.extractDimensionScores(session), + ]; + + const questionRows = [ + ['题号', '题目内容', '用户回答', '分数', '反馈', '是否追问'], + ]; + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const a = answerMap.get(q.id); + questionRows.push([ + (i + 1).toString(), + q.content || q.questionText || '', + a?.userAnswer || '', + a?.score?.toString() || '', + a?.feedback || '', + a?.isFollowUp ? '是' : '否', + ]); + } + + const wb = XLSX.utils.book_new(); + + const summaryWs = XLSX.utils.aoa_to_sheet(summarySheet); + XLSX.utils.book_append_sheet(wb, summaryWs, '摘要'); + + const questionWs = XLSX.utils.aoa_to_sheet(questionRows); + XLSX.utils.book_append_sheet(wb, questionWs, '题目详情'); + + if (session.finalReport) { + const reportSheet = this.wrapTextToLines(session.finalReport, 80); + const reportWs = XLSX.utils.aoa_to_sheet(reportSheet); + XLSX.utils.book_append_sheet(wb, reportWs, '评估报告'); + } + + const buffer = XLSX.write(wb, { bookType: 'xlsx', type: 'buffer' }); + return Buffer.from(buffer); + } + + private extractDimensionScores(session: AssessmentSession): any[][] { + const scores = session.templateJson?.dimensionScores || session.finalReport; + if (!scores) return [['未找到维度分数']]; + + if (typeof scores === 'string') { + return [['维度分数', scores]]; + } + + return Object.entries(scores).map(([key, value]) => [key, value]); + } + + private wrapTextToLines(text: string, maxWidth: number): string[][] { + const lines: string[] = []; + const paragraphs = text.split('\n'); + + for (const paragraph of paragraphs) { + if (paragraph.trim() === '') { + lines.push(''); + continue; + } + + const words = paragraph.split(' '); + let currentLine = ''; + + for (const word of words) { + if ((currentLine + ' ' + word).trim().length <= maxWidth) { + currentLine = (currentLine + ' ' + word).trim(); + } else { + if (currentLine) lines.push([currentLine]); + currentLine = word; + } + } + if (currentLine) lines.push([currentLine]); + } + + return lines.map((l) => [l]); + } + + async exportToPdf(sessionId: string): Promise { + const session = await this.sessionRepository.findOne({ + where: { id: sessionId }, + relations: ['template', 'user'], + }); + + if (!session) { + throw new Error('Session not found'); + } + + const certificate = await this.certificateRepository.findOne({ + where: { sessionId }, + }); + + const questions = await this.questionRepository.find({ + where: { sessionId }, + order: { order: 'ASC' }, + }); + + const answers = await this.answerRepository.find({ + where: { questionId: In(questions.map((q) => q.id)) }, + }); + + const content = this.generatePdfContent(session, questions, answers, certificate); + return Buffer.from(content, 'utf-8'); + } + + private generatePdfContent( + session: AssessmentSession, + questions: AssessmentQuestion[], + answers: AssessmentAnswer[], + certificate: AssessmentCertificate | null, + ): string { + const lines: string[] = []; + + lines.push('='.repeat(60)); + lines.push(' 人才评估报告'); + lines.push('='.repeat(60)); + lines.push(''); + + lines.push(`评估ID: ${session.id}`); + lines.push(`用户: ${session.user?.name || session.userId}`); + lines.push(`状态: ${session.status === 'COMPLETED' ? '已完成' : '进行中'}`); + lines.push(`最终分数: ${session.finalScore || '-'}`); + lines.push(`评估模板: ${session.template?.name || session.templateJson?.name || '-'}`); + lines.push(`评估时间: ${session.startedAt ? new Date(session.startedAt).toLocaleString() : '-'}`); + lines.push(''); + + if (certificate) { + lines.push('-'.repeat(60)); + lines.push('证书信息'); + lines.push('-'.repeat(60)); + lines.push(`等级: ${certificate.level}`); + lines.push(`总分: ${certificate.totalScore}`); + lines.push(`是否通过: ${certificate.passed ? '是' : '否'}`); + lines.push(`颁发时间: ${certificate.issuedAt ? new Date(certificate.issuedAt).toLocaleString() : '-'}`); + lines.push(''); + } + + lines.push('-'.repeat(60)); + lines.push('题目详情'); + lines.push('-'.repeat(60)); + + const answerMap = new Map(answers.map((a) => [a.questionId, a])); + + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const a = answerMap.get(q.id); + lines.push(''); + lines.push(`第${i + 1}题:`); + lines.push(` 题目: ${q.content || q.questionText || '-'}`); + lines.push(` 用户回答: ${a?.userAnswer || '-'}`); + lines.push(` 得分: ${a?.score ?? '-'}`); + lines.push(` 反馈: ${a?.feedback || '-'}`); + lines.push(` 追问: ${a?.isFollowUp ? '是' : '否'}`); + } + + if (session.finalReport) { + lines.push(''); + lines.push('-'.repeat(60)); + lines.push('综合评估报告'); + lines.push('-'.repeat(60)); + lines.push(session.finalReport); + } + + lines.push(''); + lines.push('='.repeat(60)); + lines.push(' 报告结束'); + lines.push('='.repeat(60)); + + return lines.join('\n'); + } +} \ No newline at end of file diff --git a/server/src/assessment/services/question-bank.service.ts b/server/src/assessment/services/question-bank.service.ts index fd51485..ea02cb3 100644 --- a/server/src/assessment/services/question-bank.service.ts +++ b/server/src/assessment/services/question-bank.service.ts @@ -196,6 +196,11 @@ export class QuestionBankService { if (bank.status === QuestionBankStatus.PUBLISHED) { return bank; } + if (bank.status !== QuestionBankStatus.PUBLISHED && bank.status !== QuestionBankStatus.REJECTED) { + throw new ForbiddenException( + 'Only PUBLISHED or REJECTED status can be re-published', + ); + } bank.status = QuestionBankStatus.PUBLISHED; this.logger.log(`QuestionBank ${id} published`); return this.bankRepository.save(bank); @@ -254,6 +259,15 @@ export class QuestionBankService { tenantId: string, ): Promise { const bank = await this.findOne(bankId); + + if (count <= 0 || count > 50) { + throw new Error('生成数量必须在 1-50 之间'); + } + + if (!knowledgeBaseContent || knowledgeBaseContent.trim().length < 10) { + throw new Error('知识库内容太短,无法生成有效题目'); + } + this.logger.log(`[generateQuestions] Starting AI generation for bank ${bankId}, count: ${count}`); const modelConfig = await this.modelConfigService.findDefaultByType( @@ -338,11 +352,13 @@ export class QuestionBankService { basis: q.basis, status: QuestionBankItemStatus.PENDING_REVIEW, }); - items.push(await this.itemRepository.save(item)); + items.push(item); } - this.logger.log(`[generateQuestions] Generated ${items.length} questions for bank ${bankId}`); - return items; + const savedItems = await this.itemRepository.save(items); + + this.logger.log(`[generateQuestions] Generated ${savedItems.length} questions for bank ${bankId}`); + return savedItems; } catch (error) { this.logger.error('[generateQuestions] Error generating questions:', error); throw error; @@ -371,15 +387,14 @@ export class QuestionBankService { const usedIds = new Set(); const selected: QuestionBankItem[] = []; + const availableItems = [...allItems]; let dimIdx = 0; - while (selected.length < count && usedIds.size < allItems.length) { + while (selected.length < count && availableItems.length > 0) { const dim = DIMENSIONS[dimIdx % DIMENSIONS.length]; dimIdx++; - if (selected.length >= count) break; - - const available = allItems.filter( + const available = availableItems.filter( (i) => i.dimension === dim && !usedIds.has(i.id), ); @@ -388,7 +403,27 @@ export class QuestionBankService { const item = available[idx]; selected.push(item); usedIds.add(item.id); + const actualIdx = availableItems.findIndex(i => i.id === item.id); + if (actualIdx > -1) { + availableItems.splice(actualIdx, 1); + } } + + if (dimIdx >= DIMENSIONS.length * 3) { + break; + } + } + + if (selected.length < count && availableItems.length > 0) { + const shuffled = this.shuffleArray([...availableItems]); + for (const item of shuffled) { + if (selected.length >= count) break; + if (!usedIds.has(item.id)) { + selected.push(item); + usedIds.add(item.id); + } + } + } } if (selected.length < count) { @@ -440,4 +475,13 @@ export class QuestionBankService { this.logger.log(`[batchReview] ${items.length} items ${approved ? 'approved' : 'rejected'}`); return items; } + + private shuffleArray(array: T[]): T[] { + const result = [...array]; + for (let i = result.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [result[i], result[j]] = [result[j], result[i]]; + } + return result; + } } \ No newline at end of file diff --git a/web/components/views/AssessmentView.tsx b/web/components/views/AssessmentView.tsx index 84885e3..51064fa 100644 --- a/web/components/views/AssessmentView.tsx +++ b/web/components/views/AssessmentView.tsx @@ -50,6 +50,7 @@ export const AssessmentView: React.FC = ({ const [showBasis, setShowBasis] = useState(false); const [templates, setTemplates] = useState([]); const [selectedTemplate, setSelectedTemplate] = useState(null); + const [timeCheck, setTimeCheck] = useState<{ totalTimeRemaining: number; questionTimeRemaining: number; isTotalTimeout: boolean; isQuestionTimeout: boolean } | null>(null); const messagesEndRef = useRef(null); @@ -91,6 +92,27 @@ export const AssessmentView: React.FC = ({ fetchHistory(); }, []); + useEffect(() => { + if (!session || session.status !== 'IN_PROGRESS') { + setTimeCheck(null); + return; + } + const checkTime = async () => { + try { + const data = await assessmentService.checkTimeLimits(session.id); + setTimeCheck(data); + if (data.isTotalTimeout || data.isQuestionTimeout) { + setError(t('timeLimitExceeded')); + } + } catch (err) { + console.error('Failed to check time:', err); + } + }; + checkTime(); + const interval = setInterval(checkTime, 10000); + return () => clearInterval(interval); + }, [session]); + const isZh = language === 'zh'; const isJa = language === 'ja'; @@ -502,6 +524,12 @@ export const AssessmentView: React.FC = ({ {processStep || t('aiIsProcessing')} )} + {timeCheck && ( +
+ + {Math.floor(timeCheck.totalTimeRemaining / 60)}:{String(timeCheck.totalTimeRemaining % 60).padStart(2, '0')} +
+ )} @@ -745,10 +773,64 @@ export const AssessmentView: React.FC = ({ {t('newAssessmentSession')} + + diff --git a/web/services/assessmentService.ts b/web/services/assessmentService.ts index 5b66707..8060453 100644 --- a/web/services/assessmentService.ts +++ b/web/services/assessmentService.ts @@ -28,6 +28,40 @@ export interface AssessmentState { finalScore?: number; } +export interface Certificate { + id: string; + level: string; + totalScore: number; + passed: boolean; + issuedAt: string; + qrCode?: string; + dimensionScores?: Record; +} + +export interface TimeCheck { + totalTimeRemaining: number; + questionTimeRemaining: number; + isTotalTimeout: boolean; + isQuestionTimeout: boolean; +} + +export interface StatsData { + totalAssessments: number; + averageScore: number; + completionRate: number; + passRate: number; +} + +export interface RadarData { + dimensions: Record; +} + +export interface TrendData { + date: string; + score: number; + count: number; +} + export class AssessmentService { async startSession(knowledgeBaseId: string, language: string, templateId?: string): Promise { const { data } = await apiClient.post('/assessment/start', { knowledgeBaseId, language, templateId }); @@ -43,7 +77,12 @@ export class AssessmentService { } async getHistory(): Promise { - const { data } = await apiClient.get('/assessment'); + const { data } = await apiClient.get('/assessment/history'); + return data; + } + + async getUserHistory(): Promise { + const { data } = await apiClient.get('/assessment/history'); return data; } @@ -51,6 +90,75 @@ export class AssessmentService { await apiClient.delete(`/assessment/${sessionId}`); } + async getCertificate(sessionId: string): Promise { + const { data } = await apiClient.get(`/assessment/${sessionId}/certificate`); + return data; + } + + async reviewAssessment(sessionId: string, newScore: number, comment?: string): Promise { + const { data } = await apiClient.put(`/assessment/${sessionId}/review`, { newScore, comment }); + return data; + } + + async getStats(startDate?: string, endDate?: string, templateId?: string, knowledgeGroupId?: string): Promise { + const params = new URLSearchParams(); + if (startDate) params.append('startDate', startDate); + if (endDate) params.append('endDate', endDate); + if (templateId) params.append('templateId', templateId); + if (knowledgeGroupId) params.append('knowledgeGroupId', knowledgeGroupId); + const { data } = await apiClient.get(`/assessment/stats?${params.toString()}`); + return data; + } + + async getRadarStats(templateId?: string): Promise { + const params = templateId ? `?templateId=${templateId}` : ''; + const { data } = await apiClient.get(`/assessment/stats/radar${params}`); + return data; + } + + async getTrendStats(startDate?: string, endDate?: string): Promise { + const params = new URLSearchParams(); + if (startDate) params.append('startDate', startDate); + if (endDate) params.append('endDate', endDate); + const { data } = await apiClient.get(`/assessment/stats/trend?${params.toString()}`); + return data; + } + + async checkTimeLimits(sessionId: string): Promise { + const { data } = await apiClient.get(`/assessment/${sessionId}/time-check`); + return data; + } + + async startNextQuestion(sessionId: string): Promise<{ success: boolean }> { + const { data } = await apiClient.post<{ success: boolean }>(`/assessment/${sessionId}/next-question`, {}); + return data; + } + + async exportExcel(sessionId: string): Promise<{ filename: string; buffer: string }> { + const { data } = await apiClient.get<{ filename: string; buffer: string }>(`/assessment/${sessionId}/export/excel`); + return data; + } + + async exportPdf(sessionId: string): Promise<{ filename: string; content: string }> { + const { data } = await apiClient.get<{ filename: string; content: string }>(`/assessment/${sessionId}/export/pdf`); + return data; + } + + async forceEnd(sessionId: string): Promise { + const { data } = await apiClient.post(`/assessment/${sessionId}/force-end`, {}); + return data; + } + + async verifyCertificate(certificateId: string): Promise<{ valid: boolean; certificate?: Certificate; message?: string }> { + const { data } = await apiClient.get(`/assessment/certificate/verify/${certificateId}`); + return data; + } + + async getPublicCertificate(sessionId: string): Promise<{ exists: boolean; certificate?: Certificate; message?: string }> { + const { data } = await apiClient.get(`/assessment/certificate/public/${sessionId}`); + return data; + } + async *startSessionStream(sessionId: string, templateId?: string): AsyncIterableIterator { const query = templateId ? `?templateId=${templateId}` : ''; const response = await apiClient.request(`/assessment/${sessionId}/start-stream${query}`, {