From 0539adf6124d19c9deb269a56a94bcaadeb3b8b9 Mon Sep 17 00:00:00 2001 From: let5sne Date: Sat, 4 Jul 2026 16:05:50 +0800 Subject: [PATCH] feat(aihr): persist interview results and candidate material review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料 走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回; 案例整理服务增强;两张新表纳入 reset 脚本。 --- .../AihrCandidateMaterialController.java | 49 +++++ .../controller/AihrInterviewController.java | 10 + .../service/AihrCandidateMaterialService.java | 208 ++++++++++++++++++ .../dromara/aihr/service/AihrCaseService.java | 65 ++++++ .../aihr/service/AihrInterviewService.java | 121 +++++++++- .../sql/aihr_candidate_material_mysql8.sql | 20 ++ .../sql/aihr_interview_result_mysql8.sql | 26 +++ frontend/src/api/aihr/interview.ts | 55 ++++- frontend/src/views/recruit/interview.vue | 137 +++++++++++- scripts/reset-dev-db.sh | 1 + 10 files changed, 675 insertions(+), 17 deletions(-) create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java create mode 100644 backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java create mode 100644 backend/script/sql/aihr_candidate_material_mysql8.sql create mode 100644 backend/script/sql/aihr_interview_result_mysql8.sql diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java new file mode 100644 index 00000000..a44531ab --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrCandidateMaterialController.java @@ -0,0 +1,49 @@ +package org.dromara.aihr.controller; + +import lombok.RequiredArgsConstructor; +import org.dromara.aihr.service.AihrCandidateMaterialService; +import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialResponse; +import org.dromara.aihr.service.AihrCandidateMaterialService.MaterialReviewResponse; +import org.dromara.aihr.service.AihrCandidateMaterialService.ReviewRequest; +import org.dromara.common.core.domain.R; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +import java.util.List; + +@RequiredArgsConstructor +@RestController +public class AihrCandidateMaterialController { + + private final AihrCandidateMaterialService materialService; + + @PostMapping(value = "/api/aihr/mobile/candidate/materials", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public R upload(@RequestPart("file") MultipartFile file, + @RequestParam(value = "candidateId", required = false) String candidateId, + @RequestParam(value = "candidateName", required = false) String candidateName, + @RequestParam(value = "materialType", required = false) String materialType) { + return R.ok(materialService.upload(file, candidateId, candidateName, materialType)); + } + + @GetMapping("/api/aihr/mobile/candidate/materials") + public R> list(@RequestParam(value = "candidateId", required = false) String candidateId) { + return R.ok(materialService.list(candidateId)); + } + + @GetMapping("/api/aihr/hr/candidate/materials") + public R> reviewList(@RequestParam(value = "status", required = false) String status) { + return R.ok(materialService.reviewList(status)); + } + + @PostMapping("/api/aihr/hr/candidate/materials/{id}/review") + public R review(@PathVariable Long id, @RequestBody ReviewRequest request) { + return R.ok(materialService.review(id, request)); + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java index 80bed811..047f0351 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/controller/AihrInterviewController.java @@ -5,15 +5,20 @@ import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest; import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; +import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.service.AihrInterviewService; import org.dromara.common.core.domain.R; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import java.util.List; + /** * AI 面试 API。 */ @@ -38,4 +43,9 @@ public class AihrInterviewController { public R finish(@RequestBody FinishRequest request) { return R.ok(interviewService.finish(request)); } + + @GetMapping("/records") + public R> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) { + return R.ok(interviewService.records(limit)); + } } diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java new file mode 100644 index 00000000..a9cb2d7f --- /dev/null +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCandidateMaterialService.java @@ -0,0 +1,208 @@ +package org.dromara.aihr.service; + +import lombok.RequiredArgsConstructor; +import org.dromara.system.domain.vo.SysOssVo; +import org.dromara.system.service.ISysOssService; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.sql.PreparedStatement; +import java.sql.Statement; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AihrCandidateMaterialService { + + private static final String TENANT_ID = "000000"; + private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("MM-dd HH:mm"); + private static final long MAX_SIZE = 20L * 1024 * 1024; + + private final JdbcTemplate jdbcTemplate; + private final ISysOssService ossService; + + private volatile boolean tableReady; + + public MaterialResponse upload(MultipartFile file, String candidateId, String candidateName, String materialType) { + ensureTable(); + if (file == null || file.isEmpty()) { + throw new IllegalArgumentException("请先选择补充资料"); + } + if (file.getSize() > MAX_SIZE) { + throw new IllegalArgumentException("资料不能超过20MB"); + } + String fileName = clean(file.getOriginalFilename(), "candidate-material"); + String type = clean(materialType, materialType(fileName)); + SysOssVo oss = ossService.upload(file); + KeyHolder keyHolder = new GeneratedKeyHolder(); + jdbcTemplate.update(connection -> { + PreparedStatement ps = connection.prepareStatement(""" + insert into aihr_candidate_material + (tenant_id, candidate_id, candidate_name, material_type, file_name, file_size, content_type, oss_id, status, create_time, update_time) + values (?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now()) + """, Statement.RETURN_GENERATED_KEYS); + ps.setString(1, TENANT_ID); + ps.setString(2, clean(candidateId, "mobile-candidate")); + ps.setString(3, clean(candidateName, "候选人")); + ps.setString(4, type); + ps.setString(5, fileName); + ps.setLong(6, file.getSize()); + ps.setString(7, clean(file.getContentType(), "application/octet-stream")); + ps.setLong(8, oss.getOssId()); + ps.setString(9, "待审核"); + return ps; + }, keyHolder); + Number id = keyHolder.getKey(); + return new MaterialResponse(id == null ? null : id.longValue(), oss.getOssId(), fileName, type, file.getSize(), "待审核", LocalDateTime.now().format(TIME_FORMAT)); + } + + public List list(String candidateId) { + ensureTable(); + return jdbcTemplate.query(""" + select id, oss_id, file_name, material_type, file_size, status, create_time + from aihr_candidate_material + where tenant_id = ? and candidate_id = ? + order by id desc + limit 20 + """, (rs, rowNum) -> new MaterialResponse( + rs.getLong("id"), + rs.getLong("oss_id"), + rs.getString("file_name"), + rs.getString("material_type"), + rs.getLong("file_size"), + rs.getString("status"), + rs.getTimestamp("create_time").toLocalDateTime().format(TIME_FORMAT) + ), TENANT_ID, clean(candidateId, "mobile-candidate")); + } + + public List reviewList(String status) { + ensureTable(); + String normalizedStatus = clean(status, ""); + return jdbcTemplate.query(""" + select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time + from aihr_candidate_material + where tenant_id = ? and (? = '' or status = ?) + order by id desc + limit 50 + """, (rs, rowNum) -> reviewRow( + rs.getLong("id"), + rs.getString("candidate_id"), + rs.getString("candidate_name"), + rs.getLong("oss_id"), + rs.getString("file_name"), + rs.getString("material_type"), + rs.getLong("file_size"), + rs.getString("status"), + rs.getTimestamp("create_time").toLocalDateTime() + ), TENANT_ID, normalizedStatus, normalizedStatus); + } + + public MaterialReviewResponse review(Long id, ReviewRequest request) { + ensureTable(); + String status = normalizeReviewStatus(request == null ? null : request.status()); + int updated = jdbcTemplate.update(""" + update aihr_candidate_material + set status = ?, update_time = now() + where tenant_id = ? and id = ? + """, status, TENANT_ID, id); + if (updated == 0) { + throw new IllegalArgumentException("候选资料不存在"); + } + return findReview(id); + } + + private void ensureTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID', + `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', + `material_type` varchar(50) DEFAULT NULL COMMENT '资料类型', + `file_name` varchar(255) NOT NULL COMMENT '文件名', + `file_size` bigint DEFAULT 0 COMMENT '文件大小', + `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', + `oss_id` bigint NOT NULL COMMENT '对象存储ID', + `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_candidate_material_candidate` (`tenant_id`, `candidate_id`, `id`), + KEY `idx_aihr_candidate_material_review` (`tenant_id`, `status`, `id`), + KEY `idx_aihr_candidate_material_oss` (`oss_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料'; + """); + tableReady = true; + } + } + + private MaterialReviewResponse findReview(Long id) { + return jdbcTemplate.queryForObject(""" + select id, candidate_id, candidate_name, oss_id, file_name, material_type, file_size, status, create_time + from aihr_candidate_material + where tenant_id = ? and id = ? + """, (rs, rowNum) -> reviewRow( + rs.getLong("id"), + rs.getString("candidate_id"), + rs.getString("candidate_name"), + rs.getLong("oss_id"), + rs.getString("file_name"), + rs.getString("material_type"), + rs.getLong("file_size"), + rs.getString("status"), + rs.getTimestamp("create_time").toLocalDateTime() + ), TENANT_ID, id); + } + + private static MaterialReviewResponse reviewRow(Long id, String candidateId, String candidateName, Long ossId, + String fileName, String materialType, Long fileSize, String status, + LocalDateTime createTime) { + return new MaterialReviewResponse(id, candidateId, candidateName, ossId, fileName, materialType, fileSize, status, createTime.format(TIME_FORMAT)); + } + + private static String materialType(String fileName) { + String lower = fileName.toLowerCase(); + if (lower.endsWith(".pdf") || lower.endsWith(".doc") || lower.endsWith(".docx")) { + return "简历/证书"; + } + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp")) { + return "图片资料"; + } + return "补充资料"; + } + + private static String clean(String value, String fallback) { + String text = value == null ? "" : value.trim(); + return text.isEmpty() ? fallback : text; + } + + private static String normalizeReviewStatus(String status) { + String text = clean(status, "待审核"); + if ("已通过".equals(text) || "已驳回".equals(text) || "待审核".equals(text)) { + return text; + } + throw new IllegalArgumentException("审核状态只能是待审核、已通过或已驳回"); + } + + public record MaterialResponse(Long id, Long ossId, String fileName, String materialType, Long fileSize, String status, String time) { + } + + public record MaterialReviewResponse(Long id, String candidateId, String candidateName, Long ossId, String fileName, + String materialType, Long fileSize, String status, String time) { + } + + public record ReviewRequest(String status) { + } +} diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java index 7d3554b9..c1cef72b 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrCaseService.java @@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -34,7 +35,9 @@ public class AihrCaseService { private final AihrSpeechService speechService; private final AihrModelSeedService modelService; private final ObjectMapper objectMapper; + private final JdbcTemplate jdbcTemplate; private final Map cases = new ConcurrentHashMap<>(); + private volatile boolean tableReady; public UploadResponse upload(MultipartFile file, String projectExtOrgId) { if (file == null || file.isEmpty()) { @@ -48,6 +51,7 @@ public class AihrCaseService { String caseId = "case-" + UUID.randomUUID(); CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null); cases.put(caseId, state); + saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr"); } catch (IllegalStateException e) { throw e; @@ -62,6 +66,7 @@ public class AihrCaseService { CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary()); cases.put(state.id(), next); + saveCase(next, "已整理", caseTitle(summary.summary(), state.fileName()), summary.aiSummary()); return new OrganizeResponse(state.id(), summary.summary(), summary.tags(), summary.aiSummary(), summary.source()); } @@ -77,9 +82,69 @@ public class AihrCaseService { "培训组", summary.stream().map(item -> item.label() + ":" + item.text()).findFirst().orElse(state.transcript()) ); + saveCase(state, "已入库", record.title(), record.summary()); return new CurateResponse(List.of(state.id()), List.of(record), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。"); } + private void saveCase(CaseState state, String status, String title, String summary) { + ensureCaseTable(); + jdbcTemplate.update(""" + INSERT INTO aihr_case_record + (tenant_id, case_id, file_name, project_ext_org_id, title, type, status, transcript, summary, owner, create_time, update_time) + VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, '培训组', now(), now()) + ON DUPLICATE KEY UPDATE + file_name = VALUES(file_name), + project_ext_org_id = VALUES(project_ext_org_id), + title = VALUES(title), + type = VALUES(type), + status = VALUES(status), + transcript = VALUES(transcript), + summary = VALUES(summary), + update_time = now() + """, + state.id(), + state.fileName(), + firstNonBlank(state.projectExtOrgId(), ""), + truncate(title, 120), + primaryTag(state.tags()), + status, + truncate(state.transcript(), 4000), + truncate(summary, 1000) + ); + } + + private void ensureCaseTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_case_record` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `case_id` varchar(80) NOT NULL COMMENT '案例ID', + `file_name` varchar(255) DEFAULT NULL COMMENT '来源文件', + `project_ext_org_id` varchar(100) DEFAULT NULL COMMENT '项目外部组织ID', + `title` varchar(120) DEFAULT NULL COMMENT '标题', + `type` varchar(80) DEFAULT NULL COMMENT '案例类型', + `status` varchar(30) DEFAULT '已转写' COMMENT '状态', + `transcript` text DEFAULT NULL COMMENT 'ASR转写', + `summary` varchar(1000) DEFAULT NULL COMMENT '摘要', + `owner` varchar(80) DEFAULT '培训组' COMMENT '负责人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_case_record_case` (`tenant_id`, `case_id`), + KEY `idx_aihr_case_record_status` (`tenant_id`, `status`, `update_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 案例沉淀记录'; + """); + tableReady = true; + } + } + private Optional organizeWithModel(CaseState state) { String system = """ 你是物业培训案例编辑。请把转写内容整理成可复用案例。 diff --git a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java index f2e20098..9632e81a 100644 --- a/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java +++ b/backend/ruoyi-modules/ruoyi-aihr/src/main/java/org/dromara/aihr/service/AihrInterviewService.java @@ -1,5 +1,6 @@ package org.dromara.aihr.service; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; @@ -13,8 +14,11 @@ import org.dromara.aihr.domain.AihrInterviewDto.QuestionResponse; import org.dromara.aihr.domain.AihrInterviewDto.RecordResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import java.sql.ResultSet; +import java.sql.SQLException; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; @@ -34,8 +38,10 @@ public class AihrInterviewService { private final ObjectMapper objectMapper; private final AihrModelSeedService modelService; + private final JdbcTemplate jdbcTemplate; private final Map sessions = new ConcurrentHashMap<>(); private final Map candidates = buildCandidates(); + private volatile boolean tableReady; public StartResponse start(StartRequest request) { CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null); @@ -60,8 +66,9 @@ public class AihrInterviewService { } public FinishResponse finish(FinishRequest request) { + String sessionKey = request == null || isBlank(request.sessionId()) ? "iv-" + UUID.randomUUID() : clean(request.sessionId()); InterviewSession session = sessions.computeIfAbsent( - request == null ? "" : clean(request.sessionId()), + sessionKey, key -> { CandidateProfile candidate = resolveCandidate(null, key); return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); @@ -76,6 +83,8 @@ public class AihrInterviewService { } ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session)); String time = LocalDateTime.now().format(TIME_FORMAT); + RecordResponse record = new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary()); + saveResult(sessionKey, session, score); return new FinishResponse( score.total(), score.suggestion(), @@ -83,11 +92,117 @@ public class AihrInterviewService { score.summary(), score.evidence(), score.dimensions(), - List.of(new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary())), + List.of(record), score.source() ); } + public List records(int limit) { + ensureResultTable(); + int safeLimit = Math.max(1, Math.min(limit <= 0 ? 20 : limit, 100)); + return jdbcTemplate.query(""" + SELECT finished_time, candidate_name, position_code, total_score, result, ai_summary + FROM aihr_interview_result + WHERE tenant_id = '000000' + ORDER BY finished_time DESC, id DESC + LIMIT %d + """.formatted(safeLimit), this::mapRecord); + } + + private RecordResponse mapRecord(ResultSet rs, int rowNum) throws SQLException { + var finishedTime = rs.getTimestamp("finished_time"); + return new RecordResponse( + finishedTime == null ? "" : finishedTime.toLocalDateTime().format(TIME_FORMAT), + rs.getString("candidate_name"), + rs.getString("position_code"), + rs.getInt("total_score"), + rs.getString("result"), + rs.getString("ai_summary") + ); + } + + private void saveResult(String sessionId, InterviewSession session, ScoreResult score) { + ensureResultTable(); + jdbcTemplate.update(""" + INSERT INTO aihr_interview_result + (tenant_id, session_id, candidate_id, candidate_name, position_code, total_score, result, + suggestion, ai_summary, evidence, source, answers_json, dimensions_json, finished_time, create_time, update_time) + VALUES ('000000', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, now(), now(), now()) + ON DUPLICATE KEY UPDATE + candidate_id = VALUES(candidate_id), + candidate_name = VALUES(candidate_name), + position_code = VALUES(position_code), + total_score = VALUES(total_score), + result = VALUES(result), + suggestion = VALUES(suggestion), + ai_summary = VALUES(ai_summary), + evidence = VALUES(evidence), + source = VALUES(source), + answers_json = VALUES(answers_json), + dimensions_json = VALUES(dimensions_json), + finished_time = VALUES(finished_time), + update_time = now() + """, + sessionId, + session.candidate().id(), + session.candidate().name(), + session.candidate().position(), + score.total(), + score.result(), + score.suggestion(), + score.summary(), + score.evidence(), + score.source(), + json(session.answers()), + json(score.dimensions()) + ); + } + + private void ensureResultTable() { + if (tableReady) { + return; + } + synchronized (this) { + if (tableReady) { + return; + } + jdbcTemplate.execute(""" + CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '面试会话ID', + `candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID', + `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', + `position_code` varchar(120) DEFAULT NULL COMMENT '岗位', + `total_score` int DEFAULT NULL COMMENT '总分', + `result` varchar(40) DEFAULT NULL COMMENT '结论', + `suggestion` varchar(500) DEFAULT NULL COMMENT '建议', + `ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要', + `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', + `source` varchar(40) DEFAULT NULL COMMENT '评分来源', + `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', + `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', + `finished_time` datetime DEFAULT NULL COMMENT '完成时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_interview_session` (`tenant_id`, `session_id`), + KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`), + KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果'; + """); + tableReady = true; + } + } + + private String json(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + return "{}"; + } + } + private Optional generateQuestions(CandidateProfile candidate) { String system = """ 你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。 @@ -166,7 +281,7 @@ public class AihrInterviewService { } return Optional.of(new ScoreResult( total, - firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), + truncate(firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), 240), firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"), truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180), truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240), diff --git a/backend/script/sql/aihr_candidate_material_mysql8.sql b/backend/script/sql/aihr_candidate_material_mysql8.sql new file mode 100644 index 00000000..d88a2120 --- /dev/null +++ b/backend/script/sql/aihr_candidate_material_mysql8.sql @@ -0,0 +1,20 @@ +-- 候选人补充资料:文件走 sys_oss/MinIO,这里只保存候选人与附件关系和审核状态。 + +CREATE TABLE IF NOT EXISTS `aihr_candidate_material` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `candidate_id` varchar(80) NOT NULL COMMENT '候选人外部ID', + `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', + `material_type` varchar(50) DEFAULT NULL COMMENT '资料类型', + `file_name` varchar(255) NOT NULL COMMENT '文件名', + `file_size` bigint DEFAULT 0 COMMENT '文件大小', + `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME', + `oss_id` bigint NOT NULL COMMENT '对象存储ID', + `status` varchar(20) DEFAULT '待审核' COMMENT '审核状态', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_aihr_candidate_material_candidate` (`tenant_id`, `candidate_id`, `id`), + KEY `idx_aihr_candidate_material_review` (`tenant_id`, `status`, `id`), + KEY `idx_aihr_candidate_material_oss` (`oss_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 候选人补充资料'; diff --git a/backend/script/sql/aihr_interview_result_mysql8.sql b/backend/script/sql/aihr_interview_result_mysql8.sql new file mode 100644 index 00000000..bf226241 --- /dev/null +++ b/backend/script/sql/aihr_interview_result_mysql8.sql @@ -0,0 +1,26 @@ +-- AI 面试结果最小持久化表:finish 后保存评分结果、回答快照和维度分。 +-- ponytail: one result table covers MVP history and dashboard count; split question/answer tables when audit workflows need it. + +CREATE TABLE IF NOT EXISTS `aihr_interview_result` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键', + `tenant_id` varchar(20) DEFAULT '000000' COMMENT '租户编号', + `session_id` varchar(100) NOT NULL COMMENT '面试会话ID', + `candidate_id` varchar(80) DEFAULT NULL COMMENT '候选人ID', + `candidate_name` varchar(100) DEFAULT NULL COMMENT '候选人姓名', + `position_code` varchar(120) DEFAULT NULL COMMENT '岗位', + `total_score` int DEFAULT NULL COMMENT '总分', + `result` varchar(40) DEFAULT NULL COMMENT '结论', + `suggestion` varchar(500) DEFAULT NULL COMMENT '建议', + `ai_summary` varchar(1000) DEFAULT NULL COMMENT 'AI摘要', + `evidence` varchar(1000) DEFAULT NULL COMMENT '评分依据', + `source` varchar(40) DEFAULT NULL COMMENT '评分来源', + `answers_json` text DEFAULT NULL COMMENT '候选人回答JSON', + `dimensions_json` text DEFAULT NULL COMMENT '维度评分JSON', + `finished_time` datetime DEFAULT NULL COMMENT '完成时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_aihr_interview_session` (`tenant_id`, `session_id`), + KEY `idx_aihr_interview_candidate` (`tenant_id`, `candidate_id`, `finished_time`), + KEY `idx_aihr_interview_finished` (`tenant_id`, `finished_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 面试结果'; diff --git a/frontend/src/api/aihr/interview.ts b/frontend/src/api/aihr/interview.ts index ce5d6dab..75942f64 100644 --- a/frontend/src/api/aihr/interview.ts +++ b/frontend/src/api/aihr/interview.ts @@ -59,17 +59,31 @@ export type InterviewFinishResponse = { value: number | string; note: string; }>; - records: Array<{ - time: string; - candidate: string; - position: string; - score: number; - result: string; - summary: string; - }>; + records: InterviewRecord[]; source: string; }; +export type InterviewRecord = { + time: string; + candidate: string; + position: string; + score: number; + result: string; + summary: string; +}; + +export type CandidateMaterialReview = { + id: number | string; + candidateId: string; + candidateName: string; + ossId: number | string; + fileName: string; + materialType: string; + fileSize: number; + status: string; + time: string; +}; + export function startInterview(data: InterviewStartRequest): Promise> { return request({ url: '/api/recruit/interview/start', @@ -96,3 +110,28 @@ export function finishInterview(data: InterviewFinishRequest): Promise> { + return request({ + url: '/api/recruit/interview/records', + method: 'get', + params: { limit } + }); +} + +export function listCandidateMaterialsForReview(status = ''): Promise> { + return request({ + url: '/api/aihr/hr/candidate/materials', + method: 'get', + params: { status } + }); +} + +export function reviewCandidateMaterial(id: number | string, status: string): Promise> { + return request({ + url: `/api/aihr/hr/candidate/materials/${id}/review`, + method: 'post', + data: { status }, + headers: { repeatSubmit: false } + }); +} diff --git a/frontend/src/views/recruit/interview.vue b/frontend/src/views/recruit/interview.vue index c7290c2e..be610d40 100644 --- a/frontend/src/views/recruit/interview.vue +++ b/frontend/src/views/recruit/interview.vue @@ -172,15 +172,64 @@ + +
+
+
+ +

候选资料审核

+
+
+ + + + 刷新 +
+
+ + + + + + + + + + + + + + +