feat(aihr): persist interview results and candidate material review

面试记录落 aihr_interview_result(GET /records 查询),候选人补充资料
走 OSS + aihr_candidate_material,管理端 AI 面试页可审核通过/驳回;
案例整理服务增强;两张新表纳入 reset 脚本。
This commit is contained in:
2026-07-04 16:05:50 +08:00
parent c47a9bb075
commit 0539adf612
10 changed files with 675 additions and 17 deletions
@@ -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<MaterialResponse> 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<MaterialResponse>> list(@RequestParam(value = "candidateId", required = false) String candidateId) {
return R.ok(materialService.list(candidateId));
}
@GetMapping("/api/aihr/hr/candidate/materials")
public R<List<MaterialReviewResponse>> reviewList(@RequestParam(value = "status", required = false) String status) {
return R.ok(materialService.reviewList(status));
}
@PostMapping("/api/aihr/hr/candidate/materials/{id}/review")
public R<MaterialReviewResponse> review(@PathVariable Long id, @RequestBody ReviewRequest request) {
return R.ok(materialService.review(id, request));
}
}
@@ -5,15 +5,20 @@ import org.dromara.aihr.domain.AihrInterviewDto.AnswerRequest;
import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse; import org.dromara.aihr.domain.AihrInterviewDto.AnswerResponse;
import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest; import org.dromara.aihr.domain.AihrInterviewDto.FinishRequest;
import org.dromara.aihr.domain.AihrInterviewDto.FinishResponse; 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.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.dromara.aihr.service.AihrInterviewService; import org.dromara.aihr.service.AihrInterviewService;
import org.dromara.common.core.domain.R; 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.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/** /**
* AI 面试 API。 * AI 面试 API。
*/ */
@@ -38,4 +43,9 @@ public class AihrInterviewController {
public R<FinishResponse> finish(@RequestBody FinishRequest request) { public R<FinishResponse> finish(@RequestBody FinishRequest request) {
return R.ok(interviewService.finish(request)); return R.ok(interviewService.finish(request));
} }
@GetMapping("/records")
public R<List<RecordResponse>> records(@RequestParam(value = "limit", required = false, defaultValue = "20") int limit) {
return R.ok(interviewService.records(limit));
}
} }
@@ -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<MaterialResponse> 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<MaterialReviewResponse> 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) {
}
}
@@ -12,6 +12,7 @@ import org.dromara.aihr.domain.AihrCaseDto.RecordResponse;
import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse; import org.dromara.aihr.domain.AihrCaseDto.SummaryResponse;
import org.dromara.aihr.domain.AihrCaseDto.TagResponse; import org.dromara.aihr.domain.AihrCaseDto.TagResponse;
import org.dromara.aihr.domain.AihrCaseDto.UploadResponse; import org.dromara.aihr.domain.AihrCaseDto.UploadResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@@ -34,7 +35,9 @@ public class AihrCaseService {
private final AihrSpeechService speechService; private final AihrSpeechService speechService;
private final AihrModelSeedService modelService; private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final JdbcTemplate jdbcTemplate;
private final Map<String, CaseState> cases = new ConcurrentHashMap<>(); private final Map<String, CaseState> cases = new ConcurrentHashMap<>();
private volatile boolean tableReady;
public UploadResponse upload(MultipartFile file, String projectExtOrgId) { public UploadResponse upload(MultipartFile file, String projectExtOrgId) {
if (file == null || file.isEmpty()) { if (file == null || file.isEmpty()) {
@@ -48,6 +51,7 @@ public class AihrCaseService {
String caseId = "case-" + UUID.randomUUID(); String caseId = "case-" + UUID.randomUUID();
CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null); CaseState state = new CaseState(caseId, fileName, projectExtOrgId, transcript, tagsFromText(transcript), null);
cases.put(caseId, state); cases.put(caseId, state);
saveCase(state, "已转写", caseTitle(List.of(), fileName), transcript);
return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr"); return new UploadResponse(caseId, fileName, transcript, state.tags(), "real-asr");
} catch (IllegalStateException e) { } catch (IllegalStateException e) {
throw e; throw e;
@@ -62,6 +66,7 @@ public class AihrCaseService {
CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state)); CaseSummary summary = organizeWithModel(state).orElseGet(() -> localSummary(state));
CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary()); CaseState next = new CaseState(state.id(), state.fileName(), state.projectExtOrgId(), state.transcript(), summary.tags(), summary.summary());
cases.put(state.id(), next); 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()); 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()) 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), "已按真实转写内容生成培训案例,视频仍使用预渲染样片。"); 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<CaseSummary> organizeWithModel(CaseState state) { private Optional<CaseSummary> organizeWithModel(CaseState state) {
String system = """ String system = """
你是物业培训案例编辑。请把转写内容整理成可复用案例。 你是物业培训案例编辑。请把转写内容整理成可复用案例。
@@ -1,5 +1,6 @@
package org.dromara.aihr.service; package org.dromara.aihr.service;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor; 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.RecordResponse;
import org.dromara.aihr.domain.AihrInterviewDto.StartRequest; import org.dromara.aihr.domain.AihrInterviewDto.StartRequest;
import org.dromara.aihr.domain.AihrInterviewDto.StartResponse; import org.dromara.aihr.domain.AihrInterviewDto.StartResponse;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
@@ -34,8 +38,10 @@ public class AihrInterviewService {
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final AihrModelSeedService modelService; private final AihrModelSeedService modelService;
private final JdbcTemplate jdbcTemplate;
private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>(); private final Map<String, InterviewSession> sessions = new ConcurrentHashMap<>();
private final Map<String, CandidateProfile> candidates = buildCandidates(); private final Map<String, CandidateProfile> candidates = buildCandidates();
private volatile boolean tableReady;
public StartResponse start(StartRequest request) { public StartResponse start(StartRequest request) {
CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null); CandidateProfile candidate = resolveCandidate(request == null ? null : request.candidateId(), null);
@@ -60,8 +66,9 @@ public class AihrInterviewService {
} }
public FinishResponse finish(FinishRequest request) { public FinishResponse finish(FinishRequest request) {
String sessionKey = request == null || isBlank(request.sessionId()) ? "iv-" + UUID.randomUUID() : clean(request.sessionId());
InterviewSession session = sessions.computeIfAbsent( InterviewSession session = sessions.computeIfAbsent(
request == null ? "" : clean(request.sessionId()), sessionKey,
key -> { key -> {
CandidateProfile candidate = resolveCandidate(null, key); CandidateProfile candidate = resolveCandidate(null, key);
return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric"); return new InterviewSession(candidate, candidate.questions(), new ConcurrentHashMap<>(), "local-rubric");
@@ -76,6 +83,8 @@ public class AihrInterviewService {
} }
ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session)); ScoreResult score = scoreWithModel(session).orElseGet(() -> localScore(session));
String time = LocalDateTime.now().format(TIME_FORMAT); 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( return new FinishResponse(
score.total(), score.total(),
score.suggestion(), score.suggestion(),
@@ -83,11 +92,117 @@ public class AihrInterviewService {
score.summary(), score.summary(),
score.evidence(), score.evidence(),
score.dimensions(), score.dimensions(),
List.of(new RecordResponse(time, session.candidate().name(), session.candidate().position(), score.total(), score.result(), score.summary())), List.of(record),
score.source() score.source()
); );
} }
public List<RecordResponse> 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<GeneratedQuestions> generateQuestions(CandidateProfile candidate) { private Optional<GeneratedQuestions> generateQuestions(CandidateProfile candidate) {
String system = """ String system = """
你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。 你是物业 HR 面试官。请基于岗位画像生成 3 道现场面试题。
@@ -166,7 +281,7 @@ public class AihrInterviewService {
} }
return Optional.of(new ScoreResult( return Optional.of(new ScoreResult(
total, total,
firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), truncate(firstNonBlank(root.path("suggestion").asText(), "建议复试后确认现场协调能力。"), 240),
firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"), firstNonBlank(root.path("result").asText(), total >= 85 ? "建议录用" : total >= 75 ? "建议复试" : "观察"),
truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180), truncate(firstNonBlank(root.path("summary").asText(), "回答已完成,需结合人工复核。"), 180),
truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240), truncate(firstNonBlank(root.path("evidence").asText(), "依据候选人回答中的 SOP 命中、情绪安抚和反馈时限评分。"), 240),
@@ -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 候选人补充资料';
@@ -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 面试结果';
+47 -8
View File
@@ -59,17 +59,31 @@ export type InterviewFinishResponse = {
value: number | string; value: number | string;
note: string; note: string;
}>; }>;
records: Array<{ records: InterviewRecord[];
time: string;
candidate: string;
position: string;
score: number;
result: string;
summary: string;
}>;
source: string; 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<ApiResult<InterviewStartResponse>> { export function startInterview(data: InterviewStartRequest): Promise<ApiResult<InterviewStartResponse>> {
return request({ return request({
url: '/api/recruit/interview/start', url: '/api/recruit/interview/start',
@@ -96,3 +110,28 @@ export function finishInterview(data: InterviewFinishRequest): Promise<ApiResult
headers: { repeatSubmit: false } headers: { repeatSubmit: false }
}); });
} }
export function listInterviewRecords(limit = 20): Promise<ApiResult<InterviewRecord[]>> {
return request({
url: '/api/recruit/interview/records',
method: 'get',
params: { limit }
});
}
export function listCandidateMaterialsForReview(status = ''): Promise<ApiResult<CandidateMaterialReview[]>> {
return request({
url: '/api/aihr/hr/candidate/materials',
method: 'get',
params: { status }
});
}
export function reviewCandidateMaterial(id: number | string, status: string): Promise<ApiResult<CandidateMaterialReview>> {
return request({
url: `/api/aihr/hr/candidate/materials/${id}/review`,
method: 'post',
data: { status },
headers: { repeatSubmit: false }
});
}
+131 -6
View File
@@ -172,15 +172,64 @@
<el-table-column prop="summary" label="AI 摘要" min-width="260" /> <el-table-column prop="summary" label="AI 摘要" min-width="260" />
</el-table> </el-table>
</section> </section>
<section class="panel record-panel material-review-panel">
<div class="panel-title title-with-extra">
<div>
<el-icon><Tickets /></el-icon>
<h2>候选资料审核</h2>
</div>
<div class="review-actions">
<el-select v-model="materialStatus" size="small" class="review-filter" @change="loadMaterialReviews">
<el-option v-for="status in materialStatusOptions" :key="status" :label="status" :value="status" />
</el-select>
<el-button size="small" @click="loadMaterialReviews">刷新</el-button>
</div>
</div>
<el-table v-loading="materialLoading" :data="materialReviews" size="small" class="record-table">
<el-table-column prop="time" label="提交时间" width="128" />
<el-table-column prop="candidateName" label="候选人" width="120" />
<el-table-column prop="materialType" label="资料类型" width="120" />
<el-table-column label="文件" min-width="220">
<template #default="{ row }">
<div class="material-name">
<strong>{{ row.fileName }}</strong>
<small>#{{ row.id }} · OSS {{ row.ossId }}</small>
</div>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="materialTagType(row.status)" size="small">{{ row.status }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="160" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="row.status === '已通过'" @click="reviewMaterial(row, '已通过')">通过</el-button>
<el-button link type="danger" :disabled="row.status === '已驳回'" @click="reviewMaterial(row, '已驳回')">驳回</el-button>
</template>
</el-table-column>
</el-table>
</section>
</div> </div>
</template> </template>
<script setup name="RecruitInterview" lang="ts"> <script setup name="RecruitInterview" lang="ts">
import { computed, reactive, ref } from 'vue'; import { computed, onMounted, reactive, ref } from 'vue';
import { DocumentChecked, Reading, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue'; import { DocumentChecked, Reading, Refresh, Tickets, TrendCharts, UserFilled } from '@element-plus/icons-vue';
import { ElMessage } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { finishInterview, startInterview, submitInterviewAnswer, type InterviewFinishResponse } from '@/api/aihr/interview'; import {
finishInterview,
listCandidateMaterialsForReview,
listInterviewRecords,
reviewCandidateMaterial,
startInterview,
submitInterviewAnswer,
type CandidateMaterialReview,
type InterviewFinishResponse,
type InterviewRecord
} from '@/api/aihr/interview';
type InterviewQuestion = { type InterviewQuestion = {
id: string; id: string;
@@ -366,7 +415,11 @@ const apiMode = ref<'idle' | 'api' | 'fallback'>('idle');
const apiQuestions = ref<InterviewQuestion[]>([]); const apiQuestions = ref<InterviewQuestion[]>([]);
const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null); const apiScoreResult = ref<CandidateSeed['scoreResult'] | null>(null);
const answers = reactive<Record<string, string>>({}); const answers = reactive<Record<string, string>>({});
const records = ref([...baseRecords]); const records = ref<InterviewRecord[]>([...baseRecords]);
const materialReviews = ref<CandidateMaterialReview[]>([]);
const materialStatus = ref('待审核');
const materialLoading = ref(false);
const materialStatusOptions = ['全部', '待审核', '已通过', '已驳回'];
const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]); const selectedCandidate = computed(() => candidates.find((candidate) => candidate.id === candidateId.value) ?? candidates[0]);
const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions)); const questions = computed(() => (apiQuestions.value.length > 0 ? apiQuestions.value : selectedCandidate.value.questions));
@@ -430,7 +483,6 @@ const resetFlow = () => {
apiQuestions.value = []; apiQuestions.value = [];
apiScoreResult.value = null; apiScoreResult.value = null;
resetAnswers(); resetAnswers();
records.value = [...baseRecords];
}; };
const generateQuestions = async () => { const generateQuestions = async () => {
@@ -438,7 +490,6 @@ const generateQuestions = async () => {
scored.value = false; scored.value = false;
apiQuestions.value = []; apiQuestions.value = [];
apiScoreResult.value = null; apiScoreResult.value = null;
records.value = [...baseRecords];
try { try {
const { data } = await startInterview({ const { data } = await startInterview({
candidateId: selectedCandidate.value.id, candidateId: selectedCandidate.value.id,
@@ -544,7 +595,7 @@ const finishScore = async () => {
apiScoreResult.value = null; apiScoreResult.value = null;
} }
scored.value = true; scored.value = true;
records.value = [nextRecord, ...baseRecords]; records.value = [nextRecord, ...records.value.filter((item) => item.time !== nextRecord.time || item.candidate !== nextRecord.candidate)];
if (apiMode.value !== 'api') { if (apiMode.value !== 'api') {
ElMessage.success('AI 面试评分已生成,并写入本地记录'); ElMessage.success('AI 面试评分已生成,并写入本地记录');
} }
@@ -563,6 +614,51 @@ const questionStatus = (question: InterviewQuestion) => {
const questionPillClass = (question: InterviewQuestion) => { const questionPillClass = (question: InterviewQuestion) => {
return answers[question.id]?.trim() ? 'pill-success' : 'pill-warning'; return answers[question.id]?.trim() ? 'pill-success' : 'pill-warning';
}; };
const loadInterviewRecords = async () => {
try {
const { data } = await listInterviewRecords(20);
records.value = data?.length ? data : [...baseRecords];
} catch {
records.value = [...baseRecords];
}
};
const loadMaterialReviews = async () => {
materialLoading.value = true;
try {
const status = materialStatus.value === '全部' ? '' : materialStatus.value;
const { data } = await listCandidateMaterialsForReview(status);
materialReviews.value = data || [];
} catch {
ElMessage.warning('候选资料审核列表暂不可用');
} finally {
materialLoading.value = false;
}
};
const reviewMaterial = async (row: CandidateMaterialReview, status: string) => {
try {
const { data } = await reviewCandidateMaterial(row.id, status);
materialReviews.value = materialReviews.value
.map((item) => (item.id === row.id ? data : item))
.filter((item) => materialStatus.value === '全部' || item.status === materialStatus.value);
ElMessage.success(`候选资料已${status === '已通过' ? '通过' : '驳回'}`);
} catch {
ElMessage.error('候选资料审核失败');
}
};
const materialTagType = (status: string) => {
if (status === '已通过') return 'success';
if (status === '已驳回') return 'danger';
return 'warning';
};
onMounted(() => {
loadInterviewRecords();
loadMaterialReviews();
});
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -1003,6 +1099,35 @@ const questionPillClass = (question: InterviewQuestion) => {
overflow: hidden; overflow: hidden;
} }
.material-review-panel {
margin-top: 12px;
}
.review-actions {
display: flex;
gap: 8px;
align-items: center;
}
.review-filter {
width: 110px;
}
.material-name {
display: grid;
gap: 2px;
strong {
color: #172033;
font-size: 13px;
}
small {
color: #7a8597;
font-size: 12px;
}
}
@media (max-width: 1360px) { @media (max-width: 1360px) {
.interview-grid { .interview-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
+1
View File
@@ -22,6 +22,7 @@ docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_knowledge_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_model_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_practice_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_interview_result_mysql8.sql"
docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql" docker exec -i wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue < "$ROOT_DIR/backend/script/sql/aihr_org_snapshot_mysql8.sql"
docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \ docker exec wygj-mysql mysql -uroot -proot --default-character-set=utf8mb4 ry-vue \